#!/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)), "", "", "")) 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 == "": 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]}…")