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>
This commit is contained in:
2026-09-29 14:36:18 +06:00
co-authored by Claude Sonnet 5.5
parent d48ce1f50c
commit 2ec880d1c4
8 changed files with 913 additions and 16 deletions
+6 -3
View File
@@ -7,11 +7,11 @@ export GID := $(shell id -g)
RUN := $(if $(NODOCKER),,docker compose run --rm -T tex)
OUT := ross-1988-retypeset.pdf
SOURCES := build.sh tools/polish.py tools/optimize_plates.py src/preamble.tex src/colophon.tex src/errata.tex \
$(wildcard src/pages/*.tex)
SOURCES := build.sh tools/polish.py tools/gen_seal.py tools/gen_cover_mosaic.py tools/optimize_plates.py src/preamble.tex src/cover.tex src/colophon.tex \
src/errata.tex $(wildcard src/pages/*.tex)
.DEFAULT_GOAL := pdf
.PHONY: pdf build image prep plate check reprocheck verify clean distclean shell help
.PHONY: pdf build image prep plate cover check reprocheck verify clean distclean shell help
# The PDF is a build product and is not in git. This remakes it from src/ and
# plates/, neither of which needs the ProQuest scan — only prep and plate do.
@@ -43,6 +43,9 @@ reprocheck: ## every token of src/pages must reach the built PDF
# so make would skip the rebuild and verify would pass against stale output.
verify: check build reprocheck ## the full gate: check, typeset unconditionally, then prove nothing was dropped
cover: ## rebuild the cover mosaic from plates/ and fonts/
$(RUN) python3 tools/gen_cover_mosaic.py --force
shell: ## interactive shell in the container
docker compose run --rm tex bash
+10 -1
View File
@@ -21,6 +21,15 @@ trap 'rmdir work/.buildlock 2>/dev/null' EXIT INT TERM
# Three full passes run below regardless, so a stale aux buys nothing.
rm -f work/main.aux work/main.toc work/main.out
python3 tools/polish.py >/dev/null # typographic pass: src/pages -> work/pages
# Version seal: a mark generated from a hash of the sources, drawn in the
# colophon. Regenerated here so the PDF can never carry a stale one. The hash
# covers src/ only -- never main.tex, work/seal.tex or the PDF, all of which
# this build produces, which would make the seed depend on its own output.
python3 tools/gen_seal.py --content >/dev/null
# Cover mosaic. Tracked at plates/cover-mosaic.png so an ordinary build is
# fast, but remade here if it is missing, so the cover survives losing any
# derived file -- the generator needs only plates/ and fonts/, both in git.
[ -f plates/cover-mosaic.png ] || python3 tools/gen_cover_mosaic.py
# Re-encode the plates to match what they actually hold: bilevel type goes to
# lossless JBIG2, genuine halftone stays 8-bit as JPEG. Writes copies under
# work/plates-opt and repoints work/pages at them -- plates/ and src/ are
@@ -29,7 +38,7 @@ python3 tools/polish.py >/dev/null # typographic pass: src/pages -> work/pages
python3 tools/optimize_plates.py
{ cat src/preamble.tex
printf '%s\n' '\begin{document}\immediate\openout\unsurefile=unsure.log\immediate\openout\erratafile=errata.log\immediate\openout\qslipfile=qslips.log'
printf '\\input{%s}\n' work/pages/p0001.tex src/colophon.tex
printf '\\input{%s}\n' src/cover.tex work/pages/p0001.tex src/colophon.tex
for f in work/pages/p*.tex; do [ "$f" = work/pages/p0001.tex ] || printf '\\input{%s}\n' "$f"; done
printf '%s\n' '\notesection' '\printerrata' '\immediate\closeout\unsurefile\immediate\closeout\erratafile\immediate\closeout\qslipfile\end{document}'
} > src/main.tex
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+12 -12
View File
@@ -3,15 +3,22 @@
\pdfbookmark[0]{About this edition}{colophon}
\begin{center}\bfseries About this edition\end{center}
\begingroup\small\setstretch{1.2}\setlength{\parindent}{0pt}\setlength{\parskip}{0.6em}
This is a re-typeset, searchable edition of Fiona G.\,E. Ross, \emph{The Evolution of the Printed Bengali Character from 1778 to 1978} (Ph.D. thesis, School of Oriental and African Studies, University of London, 1988), made in 2026 from the ProQuest scan of 431 leaves, ProQuest number 10731406. Every page was transcribed from the page image; the OCR text layer of the scan was not used as a source.
This is a re-typeset, searchable edition of Fiona G.\,E. Ross, \emph{The Evolution of the Printed Bengali Character from 1778 to 1978} (Ph.D. thesis, School of Oriental and African Studies, University of London, 1988), made in 2026 from the ProQuest scan of 431 leaves, ProQuest number 10731406. Every page was transcribed from the page image; the scan's OCR text layer was not used as a source.
The text is reflowed and the original pagination preserved. A number in the outer margin marks where each page of the 1988 thesis begins, and the running head gives the page range, so that the Contents, the List of Plates and the author's own cross-references still refer to the original numbering; each is linked to its marker. The footnotes are set as endnotes, grouped by chapter and keeping their numbers.
The text is reflowed and the original pagination kept: a number in the outer margin marks where each page of the 1988 thesis begins, and the running head gives the page range, so that the Contents, the List of Plates and the author's own cross-references still refer to the original numbering. The footnotes are set as endnotes, grouped by chapter and keeping their numbers.
The 178 plates are taken from the scan at 300 dpi without downsampling, cropped clear of the page number, caption and scanner margins, with the captions re-set. The type specimens the author sets into the run of her own sentences are likewise cut from the scan rather than retyped, since it is their letterforms that the argument concerns.
The 178 plates are reproduced from the scan at its own 300 dpi, cropped clear of the page number, caption and scanner margins, with the captions re-set. The type specimens the author sets into the run of her own sentences are likewise cut from the scan rather than retyped, since it is their letterforms that the argument concerns.
Spelling and punctuation stand as printed, inconsistencies included: plate~110's caption reads \emph{Vy\=akarana} against plate~108's \emph{Vy\=akaraṇa}. The author's own slips are corrected and listed in the Errata; slips inside quoted matter are left as printed, since they may belong to the source quoted. Corrections she made by hand in the scanned copy are adopted. The library ownership stamps are omitted: they record where the scanned copy was shelved, not anything about the thesis.
Spelling and punctuation stand as printed, inconsistencies included. The author's own slips are corrected and listed in the Errata; slips inside quoted matter are left as printed, since they may belong to the source quoted. Corrections she made by hand in the scanned copy are adopted; the library ownership stamps are omitted.
Set by XeLaTeX in XCharter, an extension of Matthew Carter's Charter, with Liberation Sans for the margin marks and running heads and Tiro Bangla for Bengali. The cover is set in EB Garamond, its opening line in XCharter. The mark below is generated from a hash of this edition's transcribed text and changes whenever that text does: \texttt{\sealseed}.
\begin{center}
\begin{tikzpicture}[scale=0.62]\sealbody\end{tikzpicture}
\end{center}
The ProQuest notice that precedes the title page in the scan is given overleaf.\par
Set by XeLaTeX in TeX Gyre ScholaX, with Liberation Sans for the margin marks and running heads and Tiro Bangla for Bengali. The ProQuest notice that precedes the title page in the scan is given overleaf.\par
\endgroup
\clearpage\thispagestyle{plain}
@@ -29,10 +36,3 @@ All rights reserved.\\
This work is protected against unauthorized copying under Title 17, United States Code Microform Edition \textcopyright{} ProQuest LLC.\\[0.5em]
ProQuest LLC.\\ 789 East Eisenhower Parkway\\ P.O. Box 1346\\ Ann Arbor, MI 48106 -- 1346
\end{addmargin}
\vfill
\begingroup\footnotesize\setstretch{1.15}\setlength{\parindent}{0pt}
\begin{addmargin}[0.4in]{0.4in}
The notice above is reproduced as it stands in the 2017 scan, and its particulars have since moved on: ProQuest LLC has been part of Clarivate Plc since December 2021, and the imprint, the address and the terms of supply are those of the scan's date rather than of this edition's. Copyright in the thesis remains the author's, which is the part of the notice that has not changed.\par
\end{addmargin}
\endgroup
+75
View File
@@ -0,0 +1,75 @@
% Cover — leaf 1 of the edition, ahead of the title page.
%
% Every position is a fraction of \paperwidth/\paperheight, so the cover holds
% together at any trim. The mosaic is set at 1.15\paperwidth: wider than the
% page by design, so the outer letters of the word are cut by the edges
% whatever the page size, rather than at one hard-coded width.
%
% The mosaic is the word ব্যঞ্জণ filled with the historical types the thesis
% traces — chronological down the shape, Wilkins 1778 at the top through to
% 1891 at the foot, the colour running red to ink with them. The conjunct at
% its centre is the one that closes the thesis's own Bengali: plate 178
% (orig. pp.417–418) prints Clinton Seely's laser-printer design as a rotated
% dot-matrix printout.
%
% Built by tools/gen_cover_mosaic.py, which is tracked and reproduces the image
% from plates/ and fonts/ alone; build.sh remakes it if it goes missing.
% NO version mark here: the mark changes with the text, and a cover that
% changes every edit defeats ebook-reader cover caching and metadata. The
% cover is therefore entirely static; the mark lives in the colophon.
\begingroup
\pagecolor{coverpaper}
\thispagestyle{empty}
\null
\begin{tikzpicture}[remember picture,overlay]
% Title set on BASELINES (anchor=base), not box tops. With anchor=north the
% gap between two lines includes the upper line's own height, so a 22pt line
% above a 50pt line sat visibly tighter than the 50pt pair below it even
% though the coordinates were evenly spaced. Baselines are spaced to leave
% roughly equal white between the ink: 55pt where a 50pt line follows,
% 35pt where a 22pt one does.
\node[anchor=base,align=center] at ($(current page.north)+(0,-0.1250\paperheight)$)
{\fontsize{22}{26}\selectfont\itshape\textcolor{coverink}{The Evolution of the Printed}};
\node[anchor=base,align=center] at ($(current page.north)+(0,-0.1944\paperheight)$)
{\coverfont\fontsize{50}{50}\selectfont\textcolor{coverred}{BENGALI}};
\node[anchor=base,align=center] at ($(current page.north)+(0,-0.2639\paperheight)$)
{\coverfont\fontsize{50}{50}\selectfont\textcolor{coverred}{CHARACTER}};
% same face and size as the opening line, so the two bracket the red title
\node[anchor=base,align=center] at ($(current page.north)+(0,-0.3081\paperheight)$)
{\fontsize{22}{26}\selectfont\itshape\textcolor{coverink}{from 1778 to 1978}};
% Full bleed. The mosaic is the whole word, so its own centre is the word's
% bounding box -- not the conjunct. Measured on the mask, the conjunct spans
% px 2639-4586 of 6445, putting its centre 0.0605 of the width right of the
% image centre; the x-shift below cancels that so the conjunct lands on the
% page centre. The shift SCALES WITH THE WIDTH (0.0605 x 1.75), so changing
% the width without rescaling it would let the word drift off centre again.
% Width 1.75, not 1.60: at 1.60 the left trim fell at image px 1598, a vertical
% gap in the word, so the right edge cut through ink and the left sat in white
% -- only one side read as bled. 1.75 moves the cut to px 1770, inside the
% mass. Measured on the rendered page: 40 ink rows at the left trim against
% 172 at the right, where 1.60 gave 0 and 62.
\begin{scope}
\clip(current page.south west)rectangle(current page.north east);
\node[anchor=north,inner sep=0] at
($(current page.north)+(-0.1059\paperwidth,-0.372\paperheight)$)
{\includegraphics[width=1.75\paperwidth]{plates/cover-mosaic.png}};
\end{scope}
% Author centred, lifted INTO the mosaic's rectangle but clear of its ink.
% Scanned the mask for the highest band where a 28pt line (0.525in box, page
% x 0.34-0.66 with margin) meets no ink: the box top can rise to 8.540in from
% the page top before it touches the word, so the baseline sits at 0.1971 of
% the page height from the foot. That is 0.94in higher than sitting below the
% image. Dropped 0.3in from that ceiling (0.1971 -> 0.1698): flush against
% the highest clear band the line sat level with the descender fragment to
% its right and the orphan to its left -- clear of ink, but crowded on both
% flanks. The lower position keeps it in the notch with air around it.
\node[anchor=base,align=center] at ($(current page.south)+(0,0.1698\paperheight)$)
{\coverfont\fontsize{28}{34}\selectfont\textcolor{coverink}{Fiona G.\,E. Ross}};
\end{tikzpicture}
\clearpage
\pagecolor{white}
\endgroup
+20
View File
@@ -45,6 +45,26 @@
\usepackage[Bengali,Latin]{ucharclasses}
\setTransitionsFor{Bengali}{\begingroup\bnfont}{\endgroup} % any Bengali text → Tiro Bangla
\usepackage{setspace,scrextend,graphicx,xcolor,microtype,marginnote,fancyhdr,titlesec,ragged2e}
% tikz draws the cover (src/cover.tex) and the generated version mark on it
% (tools/gen_seal.py); calc is needed for the ($...$) coordinate arithmetic
% that places nodes against the page corners.
\usepackage{tikz}\usetikzlibrary{calc}
\definecolor{paper}{RGB}{255,255,255} % the mark's counters knock out to the page
\definecolor{ink}{RGB}{26,26,28} % the seal draws in this
% Cover palette and display face. These are the cover's own typography, a
% separate choice from the book's text face (CLAUDE.md), and are used only
% by src/cover.tex.
\definecolor{coverpaper}{RGB}{250,249,245}
\definecolor{coverink}{RGB}{26,26,28}
\definecolor{coverred}{RGB}{196,34,34}
% \coverfont, not \titlefont: scrextend (KOMA) already defines \titlefont
\newfontfamily\coverfont{EBGaramond-Regular.otf}[
Path=/usr/share/texlive/texmf-dist/fonts/opentype/public/ebgaramond/,
ItalicFont=EBGaramond-Italic.otf]
% Defines \sealbody and \sealseed. Written by tools/gen_seal.py, which
% build.sh runs before this file is read, so it is always current.
\IfFileExists{work/seal.tex}{\input{work/seal.tex}}%
{\newcommand{\sealbody}{}\newcommand{\sealseed}{unbuilt}}
\usepackage[bookmarks,bookmarksnumbered=false,hidelinks,pdfusetitle]{hyperref}
\usepackage{enotez}\setenotez{backref=true,totoc=false}
\hypersetup{pdftitle={The Evolution of the Printed Bengali Character from 1778 to 1978},
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
r"""Build the cover mosaic: the word ব্যঞ্জণ filled with historical Bengali type.
Tracked, and reproducible from a clean tree: everything it needs is either in
git (plates/, fonts/) or made here. Run it and it rewrites plates/cover-mosaic.png.
docker compose run --rm -T tex python3 tools/gen_cover_mosaic.py
Stages, each skipped if its output is already present (--force redoes all):
1. mask render ব্যঞ্জণ in Tiro Bangla with XeLaTeX, raster it, threshold to
a tight-cropped silhouette
2. lines cut validated single scan-lines out of three plates, one per era
3. mosaic fill the silhouette with those lines in horizontal strips,
chronological down the shape, red at the top fading to ink
WHY IT IS BUILT THIS WAY
The silhouette is the conjunct that closes the thesis's own Bengali: plate 178
(orig. pp.417-418) prints Clinton Seely's laser-printer design as a rotated
dot-matrix printout. The word, not the bare conjunct, so that the cover can run
it full-bleed and let the outer letters be cut by the trim at any page size.
Strips never overlap: each writes into a slice view of the accumulator, so a
strip has no way to address another strip's rows. An earlier version tiled
bands with `while y < band_h: y += TILE_H`, which let a row starting just
inside a band paint its full height past the edge -- one era's type landing on
top of the next.
Line crops are single scan-lines, each validated. Crops that bundled two lines,
or that carried the tail of the line above as a broken bar along their top
edge, are what made the fill look like it had a second, smaller layer of text
in it.
"""
import argparse
import glob
import os
import subprocess
import sys
import numpy as np
from PIL import Image, ImageFilter, ImageOps
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORK = os.path.join(ROOT, 'work', 'cover')
OUT = os.path.join(ROOT, 'plates', 'cover-mosaic.png')
WORD = 'ব্যঞ্জণ'
PAPER = (250, 249, 245)
INK = (26, 26, 28)
RED = (196, 34, 34) # best-recollection match to the print edition's red
# One plate per era. Each was checked by eye before being used: p0211 was
# dropped after its densest "line" turned out to be an English caption from a
# multilingual specimen sheet, which no density heuristic can tell from Bengali.
ERA_PLATES = [
(1778, 'plates/p0063.png'), # Wilkins, "A Bengali Ode", pl.19
(1832, 'plates/p0130.png'), # Serampore
(1891, 'plates/p0211.png'), # later foundry type
]
ERA_ORDER = [1778, 1832, 1891]
# ---------------------------------------------------------------- 1. mask
MASK_TEX = r"""\documentclass[12pt]{article}
\usepackage[paperwidth=30in,paperheight=8in,margin=0.3in]{geometry}
\usepackage{fontspec}
\newfontfamily\bn{TiroBangla-Regular.ttf}[Path=%(fontdir)s/,Script=Bengali]
\pagestyle{empty}
\begin{document}
\bn\fontsize{700}{700}\selectfont %(word)s
\end{document}
"""
def build_mask(force=False):
dst = os.path.join(WORK, 'word-mask.png')
if os.path.exists(dst) and not force:
return dst
os.makedirs(WORK, exist_ok=True)
tex = os.path.join(WORK, 'word.tex')
with open(tex, 'w') as f:
f.write(MASK_TEX % {'fontdir': os.path.join(ROOT, 'fonts'), 'word': WORD})
subprocess.run(['xelatex', '-interaction=nonstopmode',
f'-output-directory={WORK}', tex],
cwd=ROOT, check=True, stdout=subprocess.DEVNULL)
pdf = os.path.join(WORK, 'word.pdf')
# The text lands on page 2: the 700pt box overruns the first page. Take the
# page that actually has ink rather than assuming either one.
best = None
for page in (1, 2):
r = subprocess.run(['pdftoppm', '-png', '-r', '300', '-f', str(page),
'-l', str(page), pdf], capture_output=True, check=True)
if not r.stdout:
continue
import io
im = Image.open(io.BytesIO(r.stdout)).convert('L')
a = np.array(im)
if (a < 200).sum() > 1000 and (best is None or (a < 200).sum() > best[1]):
best = (a, int((a < 200).sum()))
if best is None:
raise SystemExit('gen_cover_mosaic: the glyph render produced no ink')
ink = best[0] < 200
ys, xs = np.nonzero(ink)
pad = 20
box = (max(0, xs.min() - pad), max(0, ys.min() - pad), xs.max() + pad, ys.max() + pad)
Image.fromarray((ink * 255).astype('uint8')).crop(box).save(dst)
return dst
# ---------------------------------------------------------------- 2. lines
def _profile(a, x0f=0.05, x1f=0.95):
W = a.shape[1]
return (a[:, int(x0f * W):int(x1f * W)] < 150).mean(1)
def _smooth(v, k=7):
return np.convolve(v, np.ones(k) / k, mode='same')
def _runs(d, thr):
out, s = [], None
for i, v in enumerate(d > thr):
if v and s is None:
s = i
elif not v and s is not None:
out.append((s, i)); s = None
if s is not None:
out.append((s, len(d)))
return out
def _single_line(crop):
d = _smooth(_profile(crop), 5)
if d.max() <= 0:
return False
return len([r for r in _runs(d, d.max() * 0.25) if r[1] - r[0] >= 4]) == 1
def _has_matra(crop, thresh=0.032):
"""True if the crop is Bengali rather than Latin.
The plates include multilingual specimen sheets: p0211 sets the same
passage in Bengali and in Latin-script Andamanese, and no measure of ink
DENSITY tells them apart -- the densest line on that plate is Latin, and
the lightest Bengali line there is lighter than both Latin ones. What does
separate them is contiguity: Bengali hangs its letters from an unbroken
matra, so some row contains a long horizontal run of ink, while Latin
letters stand apart and no row runs far. Measured on p0211: Latin lines
reach 0.021-0.022 of the width, Bengali 0.041-0.118, so the threshold sits
between the two populations with margin on both sides.
"""
ink = crop < 150
W = ink.shape[1]
best = 0
for row in ink:
cur = 0
for v in row:
cur = cur + 1 if v else 0
if cur > best:
best = cur
if best / W >= thresh: # settled; no need to scan the rest
return True
return best / W >= thresh
def _matra_placed(crop, lo=0.10, hi=0.45):
"""True if the densest row sits where a Bengali matra belongs.
Catches a crop that is half a line. The two-pass splitter divides a merged
run at even fractions, which can cut through the middle of a line and leave
a slice holding only the glyph bottoms -- still one contiguous run, so the
single-line test passes it, and still carrying matra fragments, so the
script test passes it too. But its densest row lands near its own edge
instead of a quarter of the way down. Measured on p0211: whole lines peak
at 0.22-0.29, the half-line at 0.68.
"""
d = (crop < 150).mean(1)
if len(d) < 3 or d.max() <= 0:
return False
return lo <= int(np.argmax(d)) / (len(d) - 1) <= hi
def _edge_bleed(crop):
"""Ink hard against the crop's top edge is the tail of the line above. A
Bengali line carries its matra about a quarter of the way down, so the top
rows should be nearly clear."""
d = (crop < 150).mean(1)
k = max(1, int(len(d) * 0.12))
return float(d[:k].mean())
def extract_lines(path, tag, want=12, force=False):
have = sorted(glob.glob(os.path.join(WORK, f'{tag}_*.png')))
if have and not force:
return have
a = np.array(Image.open(os.path.join(ROOT, path)).convert('L'))
H, W = a.shape
d = _smooth(_profile(a))
if d.max() <= 0:
raise SystemExit(f'gen_cover_mosaic: no text found in {path}')
runs = [r for r in _runs(d, d.max() * 0.18) if r[1] - r[0] >= 0.004 * H]
# Two passes: measure the plate's OWN line height first, then split only
# runs clearly taller than it. Splitting on a fixed fraction of page height
# cut genuine lines in half on p0063 and emitted 20px slivers.
hs = sorted(r[1] - r[0] for r in runs)
L = hs[len(hs) // 2]
cands = []
for r0, r1 in runs:
h = r1 - r0
pieces = [(r0, r1)]
if h >= 1.6 * L:
k = max(2, int(round(h / L)))
edges = [r0 + int(h * j / k) for j in range(k + 1)]
pieces = list(zip(edges[:-1], edges[1:]))
for p0, p1 in pieces:
ph = p1 - p0
if not (0.60 * L <= ph <= 1.45 * L):
continue
pad = max(2, int(ph * 0.10))
y0, y1 = max(0, p0 - pad), min(H, p1 + pad)
if y0 == 0 or y1 == H:
continue
crop = a[y0:y1, int(0.05 * W):int(0.95 * W)]
dens = (crop < 150).mean()
if not (0.05 < dens < 0.55) or not _single_line(crop):
continue
if not _has_matra(crop): # Latin line on a multilingual plate
continue
if not _matra_placed(crop): # half a line, cut through the glyphs
continue
cands.append((_edge_bleed(crop), -dens, crop))
cands.sort(key=lambda t: (t[0], t[1])) # cleanest top edge leads
kept = []
for _, _, crop in cands[:want]:
p = os.path.join(WORK, f'{tag}_{len(kept)}.png')
Image.fromarray(crop).save(p)
kept.append(p)
if len(kept) < 3:
raise SystemExit(f'gen_cover_mosaic: only {len(kept)} usable lines from {path}')
return kept
# ---------------------------------------------------------------- 3. mosaic
def clean_ink(path, target_h):
src = Image.open(path).convert('L')
sc = target_h / src.height
tile = src.resize((max(1, int(src.width * sc)), target_h), Image.LANCZOS)
tile = ImageOps.autocontrast(tile, cutoff=0.5)
arr = np.array(tile).astype(np.float32)
ink = np.where(arr < 165, 255.0, 0.0)
return np.array(Image.fromarray(ink.astype('uint8'))
.filter(ImageFilter.GaussianBlur(2.2))).astype(np.float32)
def compose(mask_path, sources, n_strips, mask_blur, out_path, mono=False):
mask_im = Image.open(mask_path).convert('L')
alpha = np.array(mask_im.filter(ImageFilter.GaussianBlur(mask_blur))).astype(float) / 255.0
W, H = mask_im.size
rel = np.linspace(1.35, 0.65, n_strips) # size gradient, largest at top
heights = np.round(rel / rel.sum() * H).astype(int)
heights[-1] += H - heights.sum()
# 1778 took an even third and, because its strips are the tall ones, 46% of
# the height; trimmed so the later eras start higher up.
share = (0.30, 0.32, 0.38)
c1 = int(round(share[0] * n_strips))
c2 = c1 + int(round(share[1] * n_strips))
eras = [ERA_ORDER[0 if i < c1 else 1 if i < c2 else 2] for i in range(n_strips)]
ink_acc = np.zeros((H, W), dtype=np.float32)
colour = np.zeros((H, W, 3), dtype=float)
fill = 0.92
y = 0
for i, h in enumerate(heights):
th = int(h * fill)
ty0 = y + (h - th) // 2
assert y <= ty0 and ty0 + th <= y + h, 'strip text escapes its strip'
row = ink_acc[ty0:ty0 + th, :] # slice view: cannot reach other strips
tiles = [clean_ink(p, th) for p in sources[eras[i]]]
x, k = -((i * 137) % max(1, tiles[0].shape[1])), i
while x < W:
tile = tiles[k % len(tiles)]; k += 1 # rotate distinct crops
tw = tile.shape[1]
xx0, xx1 = max(x, 0), min(x + tw, W)
if xx1 > xx0:
sub = tile[:, (xx0 - x):(xx0 - x) + (xx1 - xx0)]
np.maximum(row[:, xx0:xx1], sub, out=row[:, xx0:xx1])
x += tw
f = i / (n_strips - 1)
colour[y:y + h] = INK if mono else tuple(RED[c] * (1 - f) + INK[c] * f for c in range(3))
y += h
assert y == H
a = alpha * np.clip(ink_acc / 255.0 * 1.15, 0, 1)
# RGBA with a transparent ground, not paper composited in. Baking paper in
# gave the cover two whites -- the page's and the image's -- and the PDF
# image pipeline shifted the latter by one level, showing as a faint
# rectangular panel. Also zero the sub-visible feather haze, which against
# dark ink drags 250 to 249.9, and round rather than truncate.
a[a < 0.02] = 0.0
rgba = np.dstack([np.rint(colour).clip(0, 255), np.rint(a * 255).clip(0, 255)])
Image.fromarray(rgba.astype('uint8'), mode='RGBA').save(out_path)
return W, H
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--force', action='store_true', help='redo mask and line crops')
ap.add_argument('--strips', type=int, default=16)
ap.add_argument('--mask-blur', type=float, default=2.0)
ap.add_argument('--out', default=OUT)
args = ap.parse_args()
os.makedirs(WORK, exist_ok=True)
mask = build_mask(force=args.force)
sources = {}
for yr, plate in ERA_PLATES:
sources[yr] = extract_lines(plate, f'v{yr}', force=args.force)
for yr in sources:
sources[yr] = sorted(sources[yr],
key=lambda p: _edge_bleed(np.array(Image.open(p).convert('L'))))
w, h = compose(mask, sources, args.strips, args.mask_blur, args.out)
print(f'cover mosaic {w}x{h} -> {os.path.relpath(args.out, ROOT)}'
f' ({args.strips} strips, '
+ ', '.join(f'{yr}:{len(v)} lines' for yr, v in sorted(sources.items())) + ')')
if __name__ == '__main__':
sys.exit(main())
+457
View File
@@ -0,0 +1,457 @@
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}")