lexora / scripts /glyph_audit.py
Abdr007's picture
Lexora — deployed tree
3fc8e60
Raw
History Blame Contribute Delete
4.54 kB
#!/usr/bin/env python3
"""Detect PDFs whose embedded font drops characters from a ligature.
This is the tool that found the defect described in AUDIT.md §3.2. The Labour Law's
font maps the ``Th`` ligature to the single codepoint ``T``, so its text layer reads
"Te employer shall" — a corruption that damages BM25 (wrong token) and the embedding
(wrong word) while looking perfectly fine in a PDF viewer.
The signal is geometric, not lexical. A glyph that draws two characters is drawn about
twice as wide as one that draws one, so a character whose advance-width distribution is
**bimodal** is drawing more than it claims. Run this against any new corpus before
trusting it:
python scripts/glyph_audit.py corpus/pdf/*.pdf
"""
from __future__ import annotations
import argparse
import collections
import itertools
import sys
from pathlib import Path
from typing import Any, Final
REPO_ROOT: Final = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "apps" / "api"))
import fitz # noqa: E402
# Widths are normalised by font size, so they are comparable across a document.
GAP_THRESHOLD: Final = 0.25
MIN_SAMPLES: Final = 8
MIN_CLUSTER: Final = 3
def _collect(
path: Path,
) -> tuple[dict[str, list[float]], dict[tuple[str, int], list[str]]]:
"""Measure every alphabetic glyph's advance width, normalised by font size."""
widths: dict[str, list[float]] = collections.defaultdict(list)
samples: dict[tuple[str, int], list[str]] = collections.defaultdict(list)
document = fitz.open(path)
try:
for page_index in range(document.page_count):
for block in document[page_index].get_text("rawdict")["blocks"]:
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line["spans"]:
_measure_span(span, widths, samples)
finally:
document.close()
return widths, samples
def _measure_span(
span: dict[str, Any],
widths: dict[str, list[float]],
samples: dict[tuple[str, int], list[str]],
) -> None:
chars: list[dict[str, Any]] = span["chars"]
size: float = float(span["size"] or 1.0)
for position, char in enumerate(chars):
code = char["c"]
if not code.isalpha():
continue
ratio = (char["bbox"][2] - char["bbox"][0]) / size
widths[code].append(ratio)
samples[(code, int(ratio * 10))].append(
"".join(c["c"] for c in chars[position : position + 7])
)
def audit(path: Path) -> list[str]:
"""Return a human-readable finding per suspicious character."""
widths, samples = _collect(path)
findings: list[str] = []
for code, observed in sorted(widths.items()):
if len(observed) < MIN_SAMPLES:
continue
ordered = sorted(observed)
# Largest gap between consecutive widths: a real bimodality shows up as one
# wide empty band, not as gradual spread.
gaps = [(b - a, a, b) for a, b in itertools.pairwise(ordered)]
if not gaps:
continue
gap, low, high = max(gaps)
if gap < GAP_THRESHOLD:
continue
narrow = [w for w in ordered if w <= low]
wide = [w for w in ordered if w >= high]
if len(narrow) < MIN_CLUSTER or len(wide) < MIN_CLUSTER:
continue
narrow_example = samples[(code, int(low * 10))][:2]
wide_example = samples[(code, int(high * 10))][:2]
findings.append(
f" {code!r}: narrow n={len(narrow)} (max {low:.2f}) e.g. {narrow_example}\n"
f" wide n={len(wide)} (min {high:.2f}) e.g. {wide_example}\n"
f" gap {gap:.2f} — the wide cluster is drawing more than one character"
)
return findings
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pdfs", nargs="+", type=Path)
args = parser.parse_args(argv)
total = 0
for path in args.pdfs:
findings = audit(path)
print(f"\n### {path.name}")
if findings:
total += len(findings)
print("\n".join(findings))
else:
print(" no bimodal glyph-width anomalies")
print(
f"\n{total} suspicious character(s). Each needs a rule in "
"app/rag/parse.py:LIGATURE_REPAIRS, or the corpus text is wrong."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())