Skip to content

Commit 112ecab

Browse files
m-messerclaude
andcommitted
feat: add Mathpix PDF extraction
in2lambda/wizard/mathpix.py: pdf_to_markdown() uploads a PDF to the Mathpix OCR API, polls for the rendered markdown, downloads any remote figures into <out_dir>/media/, and repoints the markdown at ./media/<name> so the Markdown filter's image resolution finds them. - Credentials from $MATHPIX_APP_ID / $MATHPIX_API_KEY; a missing pair raises a clear RuntimeError. - Only needs `requests` (already a core dep), so the module imports without the llm extra. - Ported and cleaned up from conversion2025/converter.py on Summer2025: print/exit calls become exceptions, the PIL round-trip is dropped (bytes are streamed straight to disk), poll interval/count are parameters. Tests mock all HTTP. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017VXb8aZqgFBjoeuuddjW6r
1 parent a6f1d9d commit 112ecab

3 files changed

Lines changed: 191 additions & 0 deletions

File tree

in2lambda/wizard/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Turn unstructured documents into the ``#``/``##`` markdown in2lambda understands.
2+
3+
The pieces here (Mathpix OCR, LLM extraction) are driven by the
4+
``in2lambda wizard`` command and need the optional ``llm`` extra plus API
5+
credentials.
6+
"""

in2lambda/wizard/mathpix.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Convert a PDF into markdown with the Mathpix OCR API.
2+
3+
Needs ``MATHPIX_APP_ID`` and ``MATHPIX_API_KEY`` in the environment (a ``.env``
4+
file is honoured by the wizard). Figures referenced by the returned markdown are
5+
downloaded next to it so the ``Markdown`` filter can pick them up.
6+
"""
7+
8+
import os
9+
import re
10+
import time
11+
from pathlib import Path
12+
13+
import requests
14+
15+
MATHPIX_PDF_ENDPOINT = "https://api.mathpix.com/v3/pdf"
16+
17+
# Matches ``![alt](https://...)`` image references in Mathpix markdown.
18+
_REMOTE_IMAGE = re.compile(r"!\[.*?\]\((https?://[^)]+)\)")
19+
20+
21+
def _headers() -> dict:
22+
"""Return the Mathpix auth headers, or raise if credentials are missing."""
23+
app_id = os.getenv("MATHPIX_APP_ID")
24+
app_key = os.getenv("MATHPIX_API_KEY")
25+
if not app_id or not app_key:
26+
raise RuntimeError(
27+
"MATHPIX_APP_ID and MATHPIX_API_KEY must be set to convert PDFs "
28+
"(see https://mathpix.com/ocr)."
29+
)
30+
return {"app_id": app_id, "app_key": app_key}
31+
32+
33+
def pdf_to_markdown(
34+
pdf_path: str,
35+
out_dir: str,
36+
poll_interval: float = 5.0,
37+
max_polls: int = 60,
38+
) -> Path:
39+
"""Convert ``pdf_path`` to markdown, writing it and its figures under ``out_dir``.
40+
41+
Args:
42+
pdf_path: Path to the source PDF.
43+
out_dir: Directory to write ``<stem>.md`` and a ``media/`` folder into.
44+
poll_interval: Seconds to wait between Mathpix "is it ready yet" polls.
45+
max_polls: How many times to poll before giving up.
46+
47+
Returns:
48+
The path to the written markdown file. Figures are saved in
49+
``<out_dir>/media/`` and referenced from the markdown as
50+
``./media/<name>``.
51+
52+
Raises:
53+
RuntimeError: if credentials are missing or Mathpix does not finish in time.
54+
"""
55+
headers = _headers()
56+
out = Path(out_dir)
57+
(out / "media").mkdir(parents=True, exist_ok=True)
58+
59+
with open(pdf_path, "rb") as pdf:
60+
response = requests.post(
61+
MATHPIX_PDF_ENDPOINT, headers=headers, files={"file": pdf}
62+
)
63+
response.raise_for_status()
64+
pdf_id = response.json()["pdf_id"]
65+
66+
markdown = _poll_for_markdown(pdf_id, headers, poll_interval, max_polls)
67+
markdown = _localise_figures(markdown, out)
68+
69+
md_path = out / f"{Path(pdf_path).stem}.md"
70+
md_path.write_text(markdown, encoding="utf-8")
71+
return md_path
72+
73+
74+
def _poll_for_markdown(
75+
pdf_id: str, headers: dict, poll_interval: float, max_polls: int
76+
) -> str:
77+
"""Poll Mathpix until the ``.md`` render of ``pdf_id`` is ready."""
78+
url = f"{MATHPIX_PDF_ENDPOINT}/{pdf_id}.md"
79+
for _ in range(max_polls):
80+
response = requests.get(url, headers=headers)
81+
if response.status_code == 200:
82+
return response.text
83+
time.sleep(poll_interval)
84+
raise RuntimeError(f"Mathpix did not finish converting {pdf_id} in time.")
85+
86+
87+
def _localise_figures(markdown: str, out_dir: Path) -> str:
88+
"""Download remote figures into ``out_dir/media`` and repoint the markdown at them."""
89+
markdown = markdown.replace("![]", "![pictureTag]")
90+
91+
for idx, url in enumerate(dict.fromkeys(_REMOTE_IMAGE.findall(markdown))):
92+
basename = os.path.basename(url).split("?")[0] or f"figure_{idx}.png"
93+
local_name = f"{idx}_{basename}"
94+
95+
image = requests.get(url)
96+
if image.status_code != 200:
97+
continue
98+
99+
(out_dir / "media" / local_name).write_bytes(image.content)
100+
markdown = markdown.replace(url, f"./media/{local_name}")
101+
102+
return markdown

tests/test_mathpix.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for the Mathpix PDF -> markdown helper. All HTTP is mocked."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from in2lambda.wizard.mathpix import pdf_to_markdown
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def _mathpix_creds(monkeypatch):
12+
monkeypatch.setenv("MATHPIX_APP_ID", "test-id")
13+
monkeypatch.setenv("MATHPIX_API_KEY", "test-key")
14+
15+
16+
def _pdf(tmp_path):
17+
pdf = tmp_path / "paper.pdf"
18+
pdf.write_bytes(b"%PDF-1.4 fake")
19+
return pdf
20+
21+
22+
def test_pdf_to_markdown_writes_md_and_localises_figures(tmp_path):
23+
pdf = _pdf(tmp_path)
24+
out_dir = tmp_path / "out"
25+
26+
post = MagicMock(status_code=200)
27+
post.json.return_value = {"pdf_id": "abc123"}
28+
md = MagicMock(
29+
status_code=200,
30+
text="# Heading\n\n![](https://cdn.mathpix.com/x/fig.png?width=8) done\n",
31+
)
32+
image = MagicMock(status_code=200, content=b"PNGBYTES")
33+
34+
with patch("in2lambda.wizard.mathpix.requests") as req:
35+
req.post.return_value = post
36+
req.get.side_effect = [md, image]
37+
md_path = pdf_to_markdown(str(pdf), str(out_dir), poll_interval=0.0)
38+
39+
assert md_path == out_dir / "paper.md"
40+
text = md_path.read_text()
41+
assert "![pictureTag](./media/0_fig.png)" in text
42+
assert (out_dir / "media" / "0_fig.png").read_bytes() == b"PNGBYTES"
43+
44+
45+
def test_pdf_to_markdown_polls_until_ready(tmp_path):
46+
pdf = _pdf(tmp_path)
47+
48+
post = MagicMock(status_code=200)
49+
post.json.return_value = {"pdf_id": "abc123"}
50+
not_ready = MagicMock(status_code=202)
51+
ready = MagicMock(status_code=200, text="# Only text, no figures\n")
52+
53+
with patch("in2lambda.wizard.mathpix.requests") as req:
54+
req.post.return_value = post
55+
req.get.side_effect = [not_ready, not_ready, ready]
56+
md_path = pdf_to_markdown(
57+
str(pdf), str(tmp_path / "out"), poll_interval=0.0, max_polls=5
58+
)
59+
60+
assert md_path.read_text().startswith("# Only text")
61+
62+
63+
def test_pdf_to_markdown_times_out(tmp_path):
64+
pdf = _pdf(tmp_path)
65+
66+
post = MagicMock(status_code=200)
67+
post.json.return_value = {"pdf_id": "abc123"}
68+
69+
with patch("in2lambda.wizard.mathpix.requests") as req:
70+
req.post.return_value = post
71+
req.get.return_value = MagicMock(status_code=202)
72+
with pytest.raises(RuntimeError, match="did not finish"):
73+
pdf_to_markdown(
74+
str(pdf), str(tmp_path / "out"), poll_interval=0.0, max_polls=3
75+
)
76+
77+
78+
def test_missing_credentials_raise(tmp_path, monkeypatch):
79+
monkeypatch.delenv("MATHPIX_APP_ID", raising=False)
80+
monkeypatch.delenv("MATHPIX_API_KEY", raising=False)
81+
82+
with pytest.raises(RuntimeError, match="MATHPIX_APP_ID"):
83+
pdf_to_markdown(str(_pdf(tmp_path)), str(tmp_path / "out"))

0 commit comments

Comments
 (0)