spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
"""
Analyze the actual heading-decision diff between spectre and pymupdf4llm
on i1040gi. For each TOC entry: does spec catch it as a heading? Does
pmu4llm? Categorize the disagreements so we know what targeted fix
would close which misses.
"""

from __future__ import annotations

import re

import pymupdf4llm
import spectre_rs


def norm(s: str) -> str:
    s = re.sub(r"[*_`]", "", s or "")
    s = re.sub(r"\s+", " ", s).strip().lower()
    return re.sub(r"[\.\:\,\;\!\?\—\-]+$", "", s)


def headings(md: str) -> set[str]:
    out: set[str] = set()
    for m in re.finditer(r"^#+\s+(.+?)$", md, re.M):
        h = norm(m.group(1))
        if h and len(h) > 1:
            out.add(h)
    return out


def main() -> None:
    path = "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/largedoc/i1040gi.pdf"
    sd = spectre_rs.Document(open(path, "rb").read())
    spec_heads = headings(sd.markdown())
    pmu_md = pymupdf4llm.to_markdown(path)
    pmu_heads = headings(pmu_md)
    toc = [norm(t.title) for t in sd.toc() if t.title]
    toc_set = {t for t in toc if t and len(t) > 2}

    # Four classes:
    # SPEC_HIT_PMU_HIT  — both catch (TP for both)
    # SPEC_HIT_PMU_MISS — spec extra detection (could be FP for spec)
    # SPEC_MISS_PMU_HIT — pmu catches, spec doesn't (load-bearing for us to fix)
    # SPEC_MISS_PMU_MISS — neither catches

    classes = {
        "both_hit": [],
        "spec_only": [],
        "pmu_only": [],
        "both_miss": [],
    }
    for t in toc_set:
        s = t in spec_heads
        p = t in pmu_heads
        if s and p:
            classes["both_hit"].append(t)
        elif s and not p:
            classes["spec_only"].append(t)
        elif p and not s:
            classes["pmu_only"].append(t)
        else:
            classes["both_miss"].append(t)

    print(f"TOC entries: {len(toc_set)}")
    print(f"  both_hit:        {len(classes['both_hit'])}  (TP for both)")
    print(f"  spec_only:       {len(classes['spec_only'])}  (spec catches, pmu misses)")
    print(f"  pmu_only:        {len(classes['pmu_only'])}  (pmu catches, spec misses) ← load-bearing")
    print(f"  both_miss:       {len(classes['both_miss'])}  (neither catches)")
    print()

    # Also count FP — detections that aren't in TOC
    spec_fp = spec_heads - toc_set
    pmu_fp = pmu_heads - toc_set
    print(f"False positives (detections not in TOC):")
    print(f"  spec: {len(spec_fp)}")
    print(f"  pmu:  {len(pmu_fp)}")
    print()

    # Show 15 examples of pmu_only — these are the headings we should catch
    print("Sample pmu_only headings (pmu catches, spec misses — fix-targets):")
    for h in sorted(classes["pmu_only"])[:20]:
        print(f"  {h!r}")
    print()

    # Show 15 examples of spec FP — over-detections
    print("Sample spec false positives (over-detections):")
    for h in sorted(spec_fp)[:20]:
        print(f"  {h!r}")


if __name__ == "__main__":
    main()