Link audit: fix three plate links from elided ranges
CLAUDE.md requires re-auditing the cross-reference rule after any change to it, and polish.py changed twice this session. tools/linkaudit.py does it: three classes checked mechanically, the fourth printed to be read. dangling targets 0 plate page mismatch (listed vs set) 0 (all 178) chapter page mismatch 0 `p. N` self-references 25 all read, all sound The finding: an elided range end is not a plate number of its own. plate_sub ran re.sub(r"\d+") over the whole range, so "pls. 146-8" linked its 8 to plate 8 (orig. p.35) and "pls. 142-5" linked its 5 to plate 5 (p.23). Three links, all pointing at the wrong plate — invisible to every other check, since the targets exist and the text is correct. The end is now expanded against the leading digits of the number it is elided against: 146-8 -> plate 148 (p.334), 142-5 -> plate 145 (p.329), both confirmed against the List of Plates and against where \plateop sets them. Worth noting what the audit also confirmed: in two footnotes the rule discriminates a citation page from a self-reference in the same sentence — "(London, 1962), p. 43; also see below, p. 63" links only the second. NOT YET BUILT: the Docker daemon is down, so make verify has not run against this change. polish.py was run on the host and the audit re-run against its output, but the PDF does not yet carry the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit every hyperlink in the edition. CLAUDE.md requires this after any change
|
||||
to polish.py's cross-reference rule.
|
||||
|
||||
Three classes are checkable mechanically:
|
||||
1. dangling — a link to page.N with no \\hypertarget{page.N} planted anywhere
|
||||
2. plates — the List of Plates says plate `no` is on page P; the plate is
|
||||
actually set by \\plateop{N}{no}. P and N must agree.
|
||||
3. chapters — a `chapter N` link must land on that chapter's opening page.
|
||||
The fourth, `p. N` self-references, is a judgement the script cannot make: those
|
||||
are printed with context to be read, which is what the rule asks for.
|
||||
"""
|
||||
import re, glob, sys
|
||||
from collections import defaultdict
|
||||
|
||||
SRC, WORK = "src/pages", "work/pages"
|
||||
|
||||
targets = set() # every page.N that exists
|
||||
plate_set = {} # plate no -> orig page it is set on
|
||||
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
for m in re.finditer(r"\\origpage\{(\d+)\}", t):
|
||||
targets.add(int(m.group(1)))
|
||||
for m in re.finditer(r"\\plateop\{(\d+)\}\{(\d+)\}", t):
|
||||
targets.add(int(m.group(1))); plate_set[int(m.group(2))] = int(m.group(1))
|
||||
|
||||
plate_listed, chap_listed = {}, {} # what the front matter claims
|
||||
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
for no, pg in re.findall(r"\\pl\{(\d+)\}\{.*?\}\{(\d+)\}", t):
|
||||
plate_listed[int(no)] = int(pg)
|
||||
for no, pg in re.findall(r"\\plx\{(\d+)\}\{.*?\}\{.*?\\pg\{(\d+)\}", t):
|
||||
plate_listed[int(no)] = int(pg)
|
||||
for lbl, pg in re.findall(r"\\tocl\{[^}]*\}\{(\d+)\}\{.*?\}\{(\d+)\}", t):
|
||||
chap_listed[int(lbl)] = int(pg)
|
||||
|
||||
chap_actual = {}
|
||||
for f in sorted(glob.glob(f"{SRC}/p*.tex")):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
m = re.search(r"\\chapnum\{Chapter (\d+)\}", t)
|
||||
o = re.search(r"\\origpage\{(\d+)\}", t)
|
||||
if m and o: chap_actual.setdefault(int(m.group(1)), int(o.group(1)))
|
||||
|
||||
links = [] # (file, target, shown, context)
|
||||
for f in sorted(glob.glob(f"{WORK}/p*.tex")):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
for m in re.finditer(r"\\(?:xref|hyperlink)\{page\.(\d+)\}\{((?:[^{}]|\{[^{}]*\})*)\}", t):
|
||||
before = re.sub(r"\s+", " ", t[max(0, m.start() - 90):m.start()])
|
||||
after = re.sub(r"\s+", " ", t[m.end():m.end() + 30])
|
||||
links.append((f[-8:-4], int(m.group(1)), m.group(2), before, after))
|
||||
for m in re.finditer(r"\\pg\{(\d+)\}", t):
|
||||
links.append((f[-8:-4], int(m.group(1)), "<pg>", "", ""))
|
||||
|
||||
print(f"{len(links)} links, {len(targets)} distinct page targets, "
|
||||
f"{len(plate_set)} plates set, {len(plate_listed)} plates listed\n")
|
||||
|
||||
dangling = sorted({(n, f) for f, n, s, b, a in links if n not in targets})
|
||||
print(f"1. DANGLING TARGETS: {len(dangling)}")
|
||||
for n, f in dangling[:20]: print(f" page.{n} linked from {f} — no target")
|
||||
|
||||
bad_plate = [(no, plate_listed[no], plate_set.get(no)) for no in sorted(plate_listed)
|
||||
if plate_set.get(no) is not None and plate_listed[no] != plate_set[no]]
|
||||
print(f"\n2. PLATE PAGE MISMATCH (listed vs where set): {len(bad_plate)}")
|
||||
for no, listed, actual in bad_plate[:20]:
|
||||
print(f" plate {no}: List of Plates says p.{listed}, \\plateop sets it on p.{actual}")
|
||||
missing = sorted(set(plate_listed) - set(plate_set))
|
||||
print(f" plates listed but never set: {missing if missing else 'none'}")
|
||||
|
||||
bad_chap = [(n, chap_listed[n], chap_actual.get(n)) for n in sorted(chap_listed)
|
||||
if chap_actual.get(n) is not None and chap_listed[n] != chap_actual[n]]
|
||||
print(f"\n3. CHAPTER PAGE MISMATCH (Contents vs \\chapnum): {len(bad_chap)}")
|
||||
for n, listed, actual in bad_chap[:20]:
|
||||
print(f" chapter {n}: Contents says p.{listed}, chapter opens on p.{actual}")
|
||||
|
||||
# polish.py wraps only the numeral, so the link TEXT is "63" — the kind is
|
||||
# carried by the word before it ("p.~", "pl.~", "chapter~").
|
||||
kind = lambda c: ("plate" if re.search(r"\bpls?\.[~ ]$", c) else
|
||||
"page" if re.search(r"\bpp?\.[~ ]$", c) else
|
||||
"chapter" if re.search(r"\bchapters?[~ ]$", c) else
|
||||
"bare-number" if re.search(r"[-,] ?$", c) else "other")
|
||||
tally = defaultdict(int); page_links = []
|
||||
for f, n, shown, before, after in links:
|
||||
if shown == "<pg>": tally["contents/plates page number"] += 1; continue
|
||||
k = kind(before)
|
||||
tally[k] += 1
|
||||
if k == "page": page_links.append((f, n, before[-72:], after))
|
||||
print("\n4. LINK KINDS")
|
||||
for k, v in sorted(tally.items(), key=lambda kv: -kv[1]): print(f" {k:30} {v}")
|
||||
print(f"\n5. `p. N` SELF-REFERENCE LINKS — READ THESE ({len(page_links)}):")
|
||||
for f, n, before, after in page_links:
|
||||
print(f" {f} -> p.{n:<4} …{before.strip()} [LINK] {after.strip()[:22]}…")
|
||||
+13
-2
@@ -81,9 +81,20 @@ def xrefs(t):
|
||||
# pl. 57 / pls. 83 and 84 — always this thesis's own plates, always linked
|
||||
def plate_sub(m):
|
||||
head, nums = m.group(1), m.group(2)
|
||||
# An elided range end ("pls. 146-8" for 146-148, "142-5" for 142-145) is
|
||||
# NOT a plate number of its own. Linking it as one sent "pls. 146-8" to
|
||||
# plate 8 and "pls. 142-5" to plate 5 — three wrong links, found by the
|
||||
# link audit 2026-09-15. Expand it from the leading digits of the number
|
||||
# it is elided against before looking the plate up.
|
||||
prev = None
|
||||
def one(mm):
|
||||
n = int(mm.group(0))
|
||||
return link(PLATE[n], mm.group(0)) if n in PLATE else mm.group(0)
|
||||
nonlocal prev
|
||||
tok = mm.group(0); n = int(tok)
|
||||
if prev is not None and n < prev:
|
||||
full = int(str(prev)[:len(str(prev)) - len(tok)] + tok)
|
||||
return link(PLATE[full], tok) if full in PLATE else tok
|
||||
prev = n
|
||||
return link(PLATE[n], tok) if n in PLATE else tok
|
||||
return head + "~" + re.sub(r"\d+", one, nums)
|
||||
t = re.sub(r"\b(pls?\.)\s+((?:\d+)(?:\s*(?:,|and|-|--)\s*\d+)*)", plate_sub, t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user