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:
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
)
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()