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
#!/usr/bin/env python3
"""Head-to-head benchmark: spectre_rs vs pdfminer.six vs pdfplumber.

Times text extraction (and pdfplumber's table extraction) on the same PDF.
Multiple iterations, prints min / median / max plus the character count
each library extracts so you can sanity-check that the speedup isn't an
artifact of one library skipping content.

Usage:
    pip install pdfminer.six pdfplumber
    cargo build --release --example parity
    python scripts/compare.py /path/to/document.pdf

The spectre_rs row is measured by shelling out to the `parity` example,
which prints `chars=...` and `time_ms=...`. That keeps this script free
of any Python ↔ Rust binding requirements.
"""
from __future__ import annotations

import argparse
import re
import statistics
import subprocess
import sys
import time
from pathlib import Path

ITERS = 5
PARITY_BIN = Path(__file__).resolve().parents[1] / "target" / "release" / "examples" / "parity"


def time_it(fn, *args, **kwargs):
    times = []
    out = None
    for _ in range(ITERS):
        t0 = time.perf_counter()
        out = fn(*args, **kwargs)
        times.append((time.perf_counter() - t0) * 1000.0)
    return min(times), statistics.median(times), max(times), out


def pdfminer_text(path: str) -> str:
    from pdfminer.high_level import extract_text
    return extract_text(path)


def pdfplumber_text(path: str) -> str:
    import pdfplumber
    with pdfplumber.open(path) as pdf:
        return "\n\n".join((p.extract_text() or "") for p in pdf.pages)


def pdfplumber_text_and_tables(path: str):
    import pdfplumber
    pages, tables = [], []
    with pdfplumber.open(path) as pdf:
        for p in pdf.pages:
            pages.append(p.extract_text() or "")
            for t in p.extract_tables() or []:
                tables.append(t)
    return pages, tables


def pymupdf_text(path: str) -> str:
    # PyMuPDF is the C-bindings library most often cited as the fastest PDF
    # reader in Python. Including it in the head-to-head answers the obvious
    # "but is it faster than pymupdf?" hiring-manager question.
    import pymupdf
    with pymupdf.open(path) as doc:
        return "\n\n".join(page.get_text() for page in doc)


def spectre_rs_run(path: str) -> tuple[float, int]:
    if not PARITY_BIN.exists():
        sys.exit(
            f"missing {PARITY_BIN}\n"
            f"build it first: cargo build --release --example parity"
        )
    times: list[float] = []
    chars = 0
    for _ in range(ITERS):
        proc = subprocess.run(
            [str(PARITY_BIN), path], capture_output=True, text=True, check=True
        )
        # Parse the "text " line for the cold call (or "text-warm-1" — pick the
        # warm number to mirror what pdfminer/pdfplumber show after page-cache hits).
        for line in proc.stdout.splitlines():
            m = re.search(r"text-warm-2\s+chars=(\d+)\s+time_ms=([\d.]+)", line)
            if m:
                chars = int(m.group(1))
                times.append(float(m.group(2)))
                break
    return statistics.median(times), chars


def fmt_row(name: str, mn: float, md: float, mx: float, chars: int, vs_spectre: float | None) -> str:
    mult = f"{vs_spectre:5.1f}x" if vs_spectre is not None else "1.0x (baseline)"
    return f"| {name:<32} | {md:7.1f} | {mn:7.1f} | {mx:7.1f} | {chars:>9,} | {mult:>14} |"


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("pdf", type=Path, help="PDF to benchmark against")
    args = p.parse_args()
    if not args.pdf.exists():
        sys.exit(f"not found: {args.pdf}")
    pdf = str(args.pdf)
    size_mb = args.pdf.stat().st_size / 1024 / 1024
    print(f"benchmark target: {pdf} ({size_mb:.2f} MB), {ITERS} iterations\n")

    spectre_med, spectre_chars = spectre_rs_run(pdf)

    rows = [
        ("spectre_rs (extract_text, warm)", spectre_med, spectre_med, spectre_med, spectre_chars, None),
    ]

    for label, fn in (
        ("pymupdf (page.get_text)", lambda: pymupdf_text(pdf)),
        ("pdfminer.six (extract_text)", lambda: pdfminer_text(pdf)),
        ("pdfplumber (extract_text/page)", lambda: pdfplumber_text(pdf)),
        ("pdfplumber (text + tables)", lambda: pdfplumber_text_and_tables(pdf)),
    ):
        mn, md, mx, out = time_it(fn)
        chars = (
            len(out)
            if isinstance(out, str)
            else sum(len(s) for s in out[0])
        )
        rows.append((label, mn, md, mx, chars, md / spectre_med))

    print("| library                          | median  | min     | max     | chars     | vs spectre_rs |")
    print("|----------------------------------|---------|---------|---------|-----------|----------------|")
    for r in rows:
        print(fmt_row(*r))


if __name__ == "__main__":
    main()