Files
codex-py/codex/parsing/mathpix.py
Tarik Moussa cd42e9abc8 fix(F-09): address review-gate findings — resource leak + idempotency
- mathpix.py + figures.py: wrap fitz page-loop in try/finally so
  doc.close() is guaranteed even on exception (no file-handle leak)
- ingest.py: DELETE FROM formulas/figures WHERE paper_id before re-insert
  so --rich re-ingest is idempotent (BIGSERIAL has no natural UNIQUE key;
  ON CONFLICT DO NOTHING was a no-op)

Gate: 158 passed, ruff clean, mypy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 02:23:21 +02:00

318 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Formula extraction from PDF pages.
Two backends are supported:
1. **MathPix** (cloud): POST the PDF to ``api.mathpix.com/v3/pdf`` and poll
for the result JSON. Requires ``MATHPIX_APP_ID`` and ``MATHPIX_APP_KEY``
environment variables (or ``.env`` file).
2. **pix2tex** (local, MIT licence): crop each detected math bounding-box at
3× resolution and run the ``LatexOCR`` model locally. Used when MathPix
credentials are absent. Model (~116 MB) is downloaded on first call.
The public entry point :func:`extract_formulas` routes to whichever backend
is available.
"""
from __future__ import annotations
import logging
import unicodedata
from pathlib import Path
from typing import Any
from codex.config import get_settings
from codex.models import FormulaChunk
logger = logging.getLogger(__name__)
# Unicode categories that indicate mathematical symbols.
_MATH_CATEGORIES: frozenset[str] = frozenset({"Sm", "So"})
# Bbox size thresholds — avoids cropping tiny index fragments.
_MIN_HEIGHT_PT = 15.0
_MIN_WIDTH_PT = 40.0
# Minimum number of math-category characters in a text block before we
# consider it a formula candidate.
_MIN_MATH_SYMBOLS = 5
# Minimum ratio of math/total chars (as float 01).
_MIN_MATH_RATIO = 0.15
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _is_math_char(ch: str) -> bool:
"""Return True if *ch* belongs to a mathematical Unicode category."""
return unicodedata.category(ch) in _MATH_CATEGORIES
def _math_ratio(text: str) -> float:
"""Return the fraction of characters in *text* that are math chars."""
if not text:
return 0.0
math_count = sum(1 for ch in text if _is_math_char(ch))
return math_count / len(text)
def _math_symbol_count(text: str) -> int:
"""Return the absolute count of math-category characters in *text*."""
return sum(1 for ch in text if _is_math_char(ch))
# ---------------------------------------------------------------------------
# pix2tex singleton
# ---------------------------------------------------------------------------
_pix2tex_model: Any = None
def _get_pix2tex_model() -> Any:
"""Lazily load and return the pix2tex LatexOCR singleton."""
global _pix2tex_model # noqa: PLW0603
if _pix2tex_model is None:
from pix2tex.cli import LatexOCR
_pix2tex_model = LatexOCR()
return _pix2tex_model
# ---------------------------------------------------------------------------
# pix2tex backend
# ---------------------------------------------------------------------------
def _extract_formulas_pix2tex(pdf_path: str) -> list[FormulaChunk]:
"""Extract LaTeX formulas from *pdf_path* using pix2tex (local OCR).
Algorithm
---------
1. Open the PDF with PyMuPDF (fitz).
2. For each page, gather text blocks; keep only blocks whose
math-symbol ratio exceeds the threshold and whose bounding box
is larger than the minimum size.
3. Crop the region at 3× resolution (better OCR quality).
4. Run pix2tex ``LatexOCR`` on the cropped image.
5. Wrap the result in a :class:`~codex.models.FormulaChunk`.
"""
import fitz
from PIL import Image
results: list[FormulaChunk] = []
paper_id = Path(pdf_path).stem
model = _get_pix2tex_model()
try:
doc: Any = fitz.open(pdf_path)
except Exception:
logger.exception("fitz.open failed for %s", pdf_path)
return results
scale = fitz.Matrix(3, 3)
try:
for page_num in range(len(doc)):
page = doc[page_num]
blocks: list[Any] = page.get_text("blocks")
for block in blocks:
# block = (x0, y0, x1, y1, text, block_no, block_type)
if len(block) < 5:
continue
x0, y0, x1, y1 = block[0], block[1], block[2], block[3]
text: str = block[4]
# Size filter
width_pt = x1 - x0
height_pt = y1 - y0
if width_pt < _MIN_WIDTH_PT or height_pt < _MIN_HEIGHT_PT:
continue
# Math content filter
if (
_math_symbol_count(text) < _MIN_MATH_SYMBOLS
or _math_ratio(text) < _MIN_MATH_RATIO
):
continue
# Crop at 3× for OCR quality
clip_rect = fitz.Rect(x0, y0, x1, y1)
crop_pix = page.get_pixmap(matrix=scale, clip=clip_rect)
img = Image.frombytes(
"RGB",
(crop_pix.width, crop_pix.height),
crop_pix.samples,
)
try:
latex: str = model(img)
except Exception:
logger.debug("pix2tex failed on page %d block, skipping", page_num + 1)
continue
if not latex or not latex.strip():
continue
context = text[:200].replace("\n", " ")
results.append(
FormulaChunk(
paper_id=paper_id,
page=page_num + 1,
raw_latex=latex.strip(),
context=context,
)
)
finally:
doc.close()
return results
# ---------------------------------------------------------------------------
# MathPix backend
# ---------------------------------------------------------------------------
def _extract_formulas_mathpix(
pdf_path: str,
app_id: str,
app_key: str,
) -> list[FormulaChunk]:
"""Extract LaTeX formulas using the MathPix v3 PDF API.
The function uploads the PDF, polls for results and parses the
returned ``latex_simplified`` list into :class:`~codex.models.FormulaChunk`
objects.
Retries with exponential back-off (via ``tenacity``) on transient HTTP
errors.
"""
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
paper_id = Path(pdf_path).stem
headers = {
"app_id": app_id,
"app_key": app_key,
}
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=30),
reraise=True,
)
def _post_pdf() -> dict[str, Any]:
with open(pdf_path, "rb") as fh:
resp = httpx.post(
"https://api.mathpix.com/v3/pdf",
headers=headers,
files={"file": fh},
timeout=60.0,
)
resp.raise_for_status()
return resp.json() # type: ignore[no-any-return]
submit_response = _post_pdf()
pdf_id: str = submit_response.get("pdf_id", "")
if not pdf_id:
logger.warning("MathPix did not return a pdf_id — aborting")
return []
# Poll until complete
import time
for _ in range(60):
poll_resp = httpx.get(
f"https://api.mathpix.com/v3/pdf/{pdf_id}.mmd",
headers=headers,
timeout=30.0,
)
if poll_resp.status_code == 200:
break
time.sleep(2)
else:
logger.warning("MathPix polling timed out for pdf_id=%s", pdf_id)
return []
# Parse response — MathPix MMD contains \[ ... \] or $...$ blocks
mmd_text: str = poll_resp.text
results: list[FormulaChunk] = []
lines = mmd_text.splitlines()
in_block = False
block_lines: list[str] = []
page = 1
for line in lines:
if line.strip().startswith("\\["):
in_block = True
block_lines = [line]
elif in_block:
block_lines.append(line)
if line.strip().endswith("\\]"):
latex = "\n".join(block_lines).strip()
results.append(
FormulaChunk(
paper_id=paper_id,
page=page,
raw_latex=latex,
context="",
)
)
in_block = False
block_lines = []
# crude page tracking via MMD page markers
if line.strip().startswith("<!-- Page"):
page += 1
return results
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_formulas(
pdf_path: str,
mathpix_app_id: str | None = None,
mathpix_app_key: str | None = None,
) -> list[FormulaChunk]:
"""Extract mathematical formulas from *pdf_path*.
Routes to the MathPix cloud backend when ``mathpix_app_id`` and
``mathpix_app_key`` are both non-empty; otherwise falls back to
the local pix2tex model (if ``PIX2TEX_FALLBACK=true``, which is
the default).
Parameters
----------
pdf_path:
Absolute or relative path to the PDF file.
mathpix_app_id:
Override for ``MATHPIX_APP_ID`` setting (optional).
mathpix_app_key:
Override for ``MATHPIX_APP_KEY`` setting (optional).
Returns
-------
list[FormulaChunk]
Extracted formulas; empty list if extraction is disabled or fails.
"""
settings = get_settings()
app_id = mathpix_app_id or settings.mathpix_app_id or ""
app_key = mathpix_app_key or settings.mathpix_app_key or ""
if app_id and app_key:
logger.info("Using MathPix backend for %s", pdf_path)
return _extract_formulas_mathpix(pdf_path, app_id, app_key)
if settings.pix2tex_fallback:
logger.info("Using pix2tex fallback backend for %s", pdf_path)
return _extract_formulas_pix2tex(pdf_path)
logger.info("Formula extraction disabled (no creds, pix2tex_fallback=False)")
return []