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
"""
Throwaway: heading-F1 sweep across a diverse PDF set with non-empty TOCs.

Imports the scoring primitives from the existing bench script so we keep
parity with the i1040gi baseline measurement.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path

sys.path.insert(0, "/mnt/c/Users/RyanJ/spectre-rs/scripts")
from bench_markdown_heading_f1 import (  # type: ignore
    normalize_heading,
    headings_from_markdown,
    prec_recall_f1,
)


def headings_from_toc(toc):
    """Override: bench helper has a precedence bug — `getattr(entry, "title", None) or entry[1]`
    triggers `entry[1]` when title is an empty string, which crashes on TocEntry objects."""
    out = set()
    for entry in toc:
        if hasattr(entry, "title"):
            title = entry.title
        elif isinstance(entry, dict):
            title = entry.get("title", "")
        else:
            title = entry[1] if len(entry) > 1 else ""
        h = normalize_heading(title or "")
        if h and len(h) > 1:
            out.add(h)
    return out

import spectre_rs
import pymupdf4llm

CACHE_PATH = Path("/tmp/spectre_extra_corpus/pmu4llm_cache.json")

CORPUS = [
    # (label, path)
    ("i1040gi",       "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/markdown/i1040gi.pdf"),
    ("p17",           "/tmp/spectre_extra_corpus/p17.pdf"),
    ("p334",          "/tmp/spectre_extra_corpus/p334.pdf"),
    ("p550",          "/tmp/spectre_extra_corpus/p550.pdf"),
    ("p525",          "/tmp/spectre_extra_corpus/p525.pdf"),
    ("p590a",         "/tmp/spectre_extra_corpus/p590a.pdf"),
    ("p15",           "/tmp/spectre_extra_corpus/p15.pdf"),
    ("p463",          "/tmp/spectre_extra_corpus/p463.pdf"),
    ("freeculture",   "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/freeculture.pdf"),
    ("arxiv_lora",    "/tmp/spectre_extra_corpus/2106.09685.pdf"),
    ("arxiv_mineru",  "/tmp/spectre_extra_corpus/2409.18839.pdf"),
    ("arxiv_tot",     "/tmp/spectre_extra_corpus/2305.10601.pdf"),
    ("arxiv_mistral", "/tmp/spectre_extra_corpus/2310.06825.pdf"),
    ("issue10900",    "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/issue10900.pdf"),
    ("xfa_imm1344e",  "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/xfa_filled_imm1344e.pdf"),
    ("file_pdfjs",    "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/file_pdfjs_test.pdf"),
    ("labelled_pages","/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/labelled_pages.pdf"),
    ("nested_outline","/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/pdfjs/pdf.js-master/test/pdfs/nested_outline.pdf"),
    ("icdar_us008",   "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/ICDAR_2013_table_evaluate-master/icdar2013-competition-dataset-with-gt/pdf/us-008.pdf"),
    ("icdar_us036",   "/mnt/c/Users/RyanJ/spectre-rs/bench-corpus/ICDAR_2013_table_evaluate-master/icdar2013-competition-dataset-with-gt/pdf/us-036.pdf"),
    # IRS publications (heading-detection corpus extension, 30+ docs).
    ("p1",     "/tmp/spectre_extra_corpus/p1.pdf"),
    ("p3",     "/tmp/spectre_extra_corpus/p3.pdf"),
    ("p15a",   "/tmp/spectre_extra_corpus/p15a.pdf"),
    ("p15b",   "/tmp/spectre_extra_corpus/p15b.pdf"),
    ("p225",   "/tmp/spectre_extra_corpus/p225.pdf"),
    ("p501",   "/tmp/spectre_extra_corpus/p501.pdf"),
    ("p502",   "/tmp/spectre_extra_corpus/p502.pdf"),
    ("p503",   "/tmp/spectre_extra_corpus/p503.pdf"),
    ("p504",   "/tmp/spectre_extra_corpus/p504.pdf"),
    ("p505",   "/tmp/spectre_extra_corpus/p505.pdf"),
    ("p514",   "/tmp/spectre_extra_corpus/p514.pdf"),
    ("p526",   "/tmp/spectre_extra_corpus/p526.pdf"),
    ("p527",   "/tmp/spectre_extra_corpus/p527.pdf"),
    ("p535",   "/tmp/spectre_extra_corpus/p535.pdf"),
    ("p536",   "/tmp/spectre_extra_corpus/p536.pdf"),
    ("p544",   "/tmp/spectre_extra_corpus/p544.pdf"),
    ("p551",   "/tmp/spectre_extra_corpus/p551.pdf"),
    ("p554",   "/tmp/spectre_extra_corpus/p554.pdf"),
    ("p556",   "/tmp/spectre_extra_corpus/p556.pdf"),
    ("p559",   "/tmp/spectre_extra_corpus/p559.pdf"),
    ("p560",   "/tmp/spectre_extra_corpus/p560.pdf"),
    ("p575",   "/tmp/spectre_extra_corpus/p575.pdf"),
    ("p590b",  "/tmp/spectre_extra_corpus/p590b.pdf"),
    ("p721",   "/tmp/spectre_extra_corpus/p721.pdf"),
    ("p908",   "/tmp/spectre_extra_corpus/p908.pdf"),
    ("p915",   "/tmp/spectre_extra_corpus/p915.pdf"),
    ("p925",   "/tmp/spectre_extra_corpus/p925.pdf"),
    ("p926",   "/tmp/spectre_extra_corpus/p926.pdf"),
    ("p936",   "/tmp/spectre_extra_corpus/p936.pdf"),
    ("p946",   "/tmp/spectre_extra_corpus/p946.pdf"),
    ("p969",   "/tmp/spectre_extra_corpus/p969.pdf"),
    ("p970",   "/tmp/spectre_extra_corpus/p970.pdf"),
    ("p974",   "/tmp/spectre_extra_corpus/p974.pdf"),
]


def load_cache() -> dict:
    if CACHE_PATH.exists():
        return json.loads(CACHE_PATH.read_text())
    return {}


def save_cache(c: dict) -> None:
    CACHE_PATH.write_text(json.dumps(c, indent=2))


def main() -> None:
    cache = load_cache()
    rows = []
    t_total = time.time()
    for label, path in CORPUS:
        p = Path(path)
        if not p.exists():
            print(f"MISS {label} {path}", file=sys.stderr)
            continue
        b = p.read_bytes()
        try:
            d = spectre_rs.Document(b)
            toc = d.toc()
        except Exception as e:
            print(f"FAIL {label}: spectre load — {e}", file=sys.stderr)
            continue
        gt = headings_from_toc(toc)
        if not gt:
            print(f"SKIP {label} (no usable TOC)", file=sys.stderr)
            continue

        # spectre
        t0 = time.time()
        spec_md = d.markdown()
        spec_t = time.time() - t0
        spec_h = headings_from_markdown(spec_md)
        s_p, s_r, s_f1 = prec_recall_f1(spec_h, gt)

        # pymupdf4llm — cache by path + mtime
        key = f"{path}|{p.stat().st_mtime_ns}"
        if key in cache:
            entry = cache[key]
            pmu_t = entry.get("seconds", -1.0)
            pmu_h_count = entry["pmu_h_count"]
            p_p, p_r, p_f1 = entry["p"], entry["r"], entry["f1"]
            cached = True
        else:
            t0 = time.time()
            pmu_md = pymupdf4llm.to_markdown(path)
            pmu_t = time.time() - t0
            pmu_h = headings_from_markdown(pmu_md)
            p_p, p_r, p_f1 = prec_recall_f1(pmu_h, gt)
            pmu_h_count = len(pmu_h)
            cache[key] = {
                "seconds": pmu_t,
                "pmu_h_count": pmu_h_count,
                "p": p_p, "r": p_r, "f1": p_f1,
            }
            save_cache(cache)
            cached = False

        row = {
            "label": label,
            "toc": len(gt),
            "spec_h": len(spec_h),
            "pmu_h": pmu_h_count,
            "spec_p": s_p, "spec_r": s_r, "spec_f1": s_f1,
            "pmu_p": p_p, "pmu_r": p_r, "pmu_f1": p_f1,
            "spec_seconds": spec_t,
            "pmu_seconds": pmu_t,
            "pmu_cached": cached,
        }
        rows.append(row)
        print(f"OK   {label:18s} TOC={len(gt):4d} "
              f"spec F1={s_f1:.2f} ({spec_t:5.2f}s) "
              f"pmu F1={p_f1:.2f} ({'cache' if cached else f'{pmu_t:5.1f}s'})",
              file=sys.stderr)

    out = {"rows": rows, "wall_seconds": time.time() - t_total}
    print(json.dumps(out, indent=2))


if __name__ == "__main__":
    main()