feat: formula_img2latex 0.1.0 – Formelbilder geprüft durch LaTeX ersetzen

Nachbearbeitung für epub2md- und pdf2md-Ausgaben:
scan → recognize (Vision-LLM lokal/OpenRouter) → verify → review → apply/revert.

- Rendern mit pdflatex (Batch, Fehlerisolierung, unsichere Befehle gesperrt),
  Schrift-Presets cm/times/txfonts/palatino/stix/euler, automatische Wahl je Buch
- Bildvergleich in Originalauflösung mit ¼-Pixel-Ausrichtung, Pixel/Punkt-Konsistenz
- Kontrasttest gegen layouttreue Verwechslungsvarianten, Selbstkorrektur per Hill-Climbing
- Pandoc/MathML-Prüfung vor jeder Annahme
- pdf2md: Formelbild per Template-Matching auf der Seite finden, 600-dpi-Neuausschnitt
- Review-Galerie auf localhost (Token-geschützt), idempotentes apply mit Sicherung, revert
- calibrate: Messung an Formelbildern mit TeX im alt-Text (0/600 Falsch-Annahmen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-07 03:00:40 +02:00
commit eb72d0b0f2
3 changed files with 4363 additions and 0 deletions

68
tests/conftest.py Normal file
View file

@ -0,0 +1,68 @@
"""Shared fixtures: synthetic formula images and a minimal epub2md output directory."""
from __future__ import annotations
import io
import json
import shutil
from pathlib import Path
import cv2
import numpy as np
import pytest
from PIL import Image
from formula_img2latex.render import render_many
needs_latex = pytest.mark.skipif(shutil.which("pdflatex") is None, reason="pdflatex fehlt")
needs_pandoc = pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc fehlt")
def synth_image(tex: str, display: bool = False, px_per_pt: float = 1.41, fmt: str = "JPEG",
offset: tuple[int, int] = (1, 2), preset: str = "cm") -> bytes:
"""Render *tex* like a small e-book formula image (Gehrke-like: ~1.4 px/pt, JPEG)."""
r = render_many([(tex, display)], preset)[0]
assert r.ok, r.error
gray = 1.0 - r.rasterize(px_per_pt * 4)
pad = np.ones((gray.shape[0] + 8, gray.shape[1] + 8), np.float32)
oy, ox = offset
pad[oy:oy + gray.shape[0], ox:ox + gray.shape[1]] = gray
h, w = pad.shape[0] // 4, pad.shape[1] // 4
small = cv2.resize(pad[: h * 4, : w * 4], (w, h), interpolation=cv2.INTER_AREA)
buf = io.BytesIO()
im = Image.fromarray((small * 255).astype(np.uint8))
if fmt == "PNG-ALPHA": # black ink on transparent background (Springer style)
rgba = Image.new("RGBA", im.size, (0, 0, 0, 0))
rgba.putalpha(Image.fromarray(255 - np.asarray(im)))
rgba.save(buf, "PNG")
else:
im.save(buf, fmt, **({"quality": 75} if fmt == "JPEG" else {}))
return buf.getvalue()
@pytest.fixture
def epub2md_dir(tmp_path: Path) -> Path:
"""Minimal epub2md output with two formula images (one inline, one display) and a figure."""
root = tmp_path / "buch"
(root / "chapters").mkdir(parents=True)
(root / "media" / "images").mkdir(parents=True)
imgs = {
"0001-inline.jpg": synth_image(r"L=\{-\sqrt{2};\sqrt{2}\}", px_per_pt=2.2),
"0002-display.jpg": synth_image(r"x_{2,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi", display=True, px_per_pt=2.2),
}
for name, data in imgs.items():
(root / "media" / "images" / name).write_bytes(data)
color = np.zeros((40, 60, 3), np.uint8)
color[:, :, 2] = 200
Image.fromarray(color).save(root / "media" / "images" / "0003-figure.png")
(root / "chapters" / "001-kapitel.md").write_text(
"---\nchapter_title: Test\n---\n\n# Kapitel\n\n"
"Die Lösungsmenge lautet ![x](../media/images/0001-inline.jpg).\n\n"
"Allgemein gilt\n\n![y](../media/images/0002-display.jpg)\n\n"
"```\n![kein](../media/images/0001-inline.jpg)\n```\n\n"
"![Abbildung](../media/images/0003-figure.png)\n", encoding="utf-8")
report = {"tool": "epub2md", "options": {"math": "auto"}, "input": {"name": "buch.epub", "sha256": "x"},
"media": {"files": [{"source": f"OEBPS/{n}", "file": f"media/images/{n}"} for n in imgs]},
"warnings": [{"code": "formula-image", "message": "Formel liegt nur als Bild vor (0002-display.jpg)."}]}
(root / "conversion-report.json").write_text(json.dumps(report), encoding="utf-8")
return root

109
tests/test_core.py Normal file
View file

@ -0,0 +1,109 @@
"""Render, compare, confusables, verify: the core question "does this LaTeX look like the image?"."""
from __future__ import annotations
import pytest
from conftest import needs_latex, needs_pandoc, synth_image
from formula_img2latex.checks import pandoc_mathml_ok
from formula_img2latex.compare import compare
from formula_img2latex.confusables import tokenize, variants
from formula_img2latex.decide import decide, thresholds
from formula_img2latex.imageio import load_ink
from formula_img2latex.render import render_many
from formula_img2latex.tex import prepare_for_render, split_tag, strip_delimiters, unsafe_reason
from formula_img2latex.verify import verify
# ----------------------------------------------------------------------------------- tex
@pytest.mark.parametrize("tex", [r"\input{/etc/passwd}", r"\def\a{\a}\a", r"\write18{rm -rf /}", r"\csname x\endcsname",
r"\catcode`\^=7", r"a^^5cinput", r"\makeatletter\@tempa", r"\frac{a}{b",
r"x}{", r"$x$", r"\begin{document}", r"\immediate\openout1=x"])
def test_unsafe_tex_is_rejected(tex: str) -> None:
assert unsafe_reason(tex)
@pytest.mark.parametrize("tex", [r"\frac{a}{b}", r"\begin{pmatrix}1&2\\3&4\end{pmatrix}", r"\text{für } x \in \mathbb{R}",
r"\sum_{i=1}^n x_i", r"\$5"])
def test_normal_tex_is_safe(tex: str) -> None:
assert unsafe_reason(tex) is None
def test_delimiters_and_tags() -> None:
assert strip_delimiters("$$x^2$$") == "x^2"
assert strip_delimiters(r"\(a\)") == "a"
assert strip_delimiters("$a$ und $b$") == "$a$ und $b$"
assert split_tag(r"a=b \tag{3.7}") == ("a=b", "3.7")
assert prepare_for_render(r"\begin{align*}a&=b\\c&=d\end{align*}") == r"\begin{aligned}a&=b\\c&=d\end{aligned}"
# -------------------------------------------------------------------------------- render
@needs_latex
def test_render_isolates_bad_formulas() -> None:
rs = render_many([(r"x^2", False), (r"\undefinedmacro", False), (r"\frac{1}{2}", True), (r"\input{x}", False)])
assert [r.ok for r in rs] == [True, False, True, False]
assert "nicht erlaubt" in (rs[3].error or "")
# ------------------------------------------------------------------------------ compare
@needs_latex
@pytest.mark.parametrize("fmt", ["JPEG", "PNG", "PNG-ALPHA", "GIF"])
def test_true_render_matches_its_image(fmt: str) -> None:
tex = r"\sum_{k=1}^{n}(a_k+b_k)"
ink = load_ink(synth_image(tex, fmt=fmt), "x." + ("png" if "PNG" in fmt else fmt.lower()))
c = compare(ink, render_many([(tex, False)])[0])
assert c.ok
assert c.metrics["ncc"] > 0.9
assert c.metrics["aspect_dev"] < 0.03
assert c.metrics["px_per_pt"] == pytest.approx(1.41, rel=0.04)
@needs_latex
def test_wrong_candidate_scores_lower() -> None:
ink = load_ink(synth_image(r"x^{2}+y^{2}=r^{2}"), "x.jpg")
good, bad = render_many([(r"x^{2}+y^{2}=r^{2}", False), (r"x_{2}+y^{2}=r^{2}", False)])
assert compare(ink, good).metrics["ncc"] > compare(ink, bad).metrics["ncc"]
# --------------------------------------------------------------------------- confusables
def test_tokenize_and_variants() -> None:
assert tokenize(r"\alpha_{2}") == ["\\alpha", "_", "{", "2", "}"]
vs = {v.tex for v in variants(r"x_{2}")}
assert r"x^{2}" in vs and r"x_{3}" in vs and r"y_{2}" in vs
vs = {v.tex for v in variants(r"N(\mu;4)")}
assert r"\mathcal{N}(\mu;4)" in vs and r"N(\mu,4)" in vs
assert all(v.tex != r"x_{2}" for v in variants(r"x_{2}"))
# -------------------------------------------------------------------------------- verify
@needs_latex
def test_verify_accepts_truth_and_repairs_single_errors() -> None:
truth = r"L=\{-\sqrt{2};\sqrt{2}\}"
ink = load_ink(synth_image(truth), "x.jpg")
v = verify(ink, truth, False, "cm")
assert v.ok and v.tex == truth and v.min_contrast is not None and v.min_contrast > 0
repaired = verify(ink, r"L=\{-\sqrt{2};\sqrt{3}\}", False, "cm")
assert repaired.tex == truth
assert repaired.climbed_from == [r"L=\{-\sqrt{2};\sqrt{3}\}"]
@needs_latex
def test_decision_rules() -> None:
th = thresholds()
ok = {"ok": True, "tex": "x", "metrics": {"ncc": 0.95, "aspect_dev": 0.0, "px_per_pt": 1.4}, "min_contrast": 0.6}
assert decide(ok, True, 1.4, th)[0] == "auto_accepted"
assert decide(ok, False, 1.4, th)[0] == "needs_review" # Pandoc fails
assert decide(ok, True, 1.7, th)[0] == "needs_review" # wrong size/style
assert decide({**ok, "min_contrast": 0.2}, True, 1.4, th)[0] == "needs_review"
assert decide({**ok, "min_contrast": -0.5}, True, 1.4, th)[0] == "rejected_keep_image"
assert decide({"ok": False, "reason": "x"}, True, None, th)[0] == "render_failed"
@needs_pandoc
def test_pandoc_check() -> None:
assert pandoc_mathml_ok([(r"\frac{a}{b}", False), (r"\mathinner{a\raise1pt\hbox{b}}", True)]) == [True, False]

108
tests/test_pipeline.py Normal file
View file

@ -0,0 +1,108 @@
"""scan → (candidates) → verify → apply → revert on a synthetic epub2md output, without any model."""
from __future__ import annotations
from pathlib import Path
from conftest import needs_latex, needs_pandoc
from formula_img2latex import pipeline
from formula_img2latex.cli import main
from formula_img2latex.engines.vlm import parse_answer, prepare_image
from formula_img2latex.sources.base import scan_markdown
from formula_img2latex.store import Workspace
def _inject(ws: Workspace, by_asset: dict[str, str]) -> None:
"""Pretend an engine answered (tests must not need a model server).
The mechanics are tested here, not the calibrated thresholds (test_core.test_decision_rules),
so the contrast threshold is set low enough for the synthetic images.
"""
ws.settings["thresholds"] = {"min_contrast": 0.1}
ws.save_meta()
for f in ws.all():
for name, tex in by_asset.items():
if f.asset.endswith(name):
f.candidates.append({"engine": "test", "model": "fixture", "tex": tex, "is_formula": True})
ws.save(f)
def test_scan_markdown_skips_code_and_detects_display(epub2md_dir: Path) -> None:
refs = scan_markdown(epub2md_dir / "chapters" / "001-kapitel.md")
assert [(r.target.rsplit("/", 1)[1], r.display) for r in refs] == [
("0001-inline.jpg", False), ("0002-display.jpg", True), ("0003-figure.png", True)]
def test_scan_classifies(epub2md_dir: Path) -> None:
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
pipeline.scan(source, ws, quiet=True)
labels = {f.asset.rsplit("/", 1)[1]: (f.classify["label"], f.known_formula) for f in ws.all()}
assert labels["0001-inline.jpg"] == ("formula", False)
assert labels["0002-display.jpg"] == ("formula", True) # epub2md warning
assert labels["0003-figure.png"][0] == "figure"
assert ws.root == epub2md_dir.parent / "buch.formulas"
@needs_latex
@needs_pandoc
def test_verify_apply_revert_roundtrip(epub2md_dir: Path) -> None:
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
pipeline.scan(source, ws, quiet=True)
_inject(ws, {"0001-inline.jpg": r"L = \{ - \sqrt{2} , \sqrt{2} \}", # comma: wrong, repaired to ;
"0002-display.jpg": r"x_{2,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi"})
counts = pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
st = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
inline = st["0001-inline.jpg"]
assert inline.verification["best"]["tex"].replace(" ", "") == r"L=\{-\sqrt{2};\sqrt{2}\}"
assert inline.status == "auto_accepted", inline.reason
assert sum(counts.values()) == 2
md = epub2md_dir / "chapters" / "001-kapitel.md"
before = md.read_text(encoding="utf-8")
pipeline.apply(source, ws, quiet=True)
after = md.read_text(encoding="utf-8")
assert "lautet $L = \\{ - \\sqrt{2} ; \\sqrt{2} \\}$." in after or "lautet $L=\\{-\\sqrt{2};\\sqrt{2}\\}$." in after
assert "```\n![kein](../media/images/0001-inline.jpg)\n```" in after # code block untouched
assert "0003-figure.png" in after
pipeline.apply(source, ws, quiet=True) # idempotent
assert md.read_text(encoding="utf-8") == after
pipeline.revert(source, ws, quiet=True)
assert md.read_text(encoding="utf-8") == before
@needs_latex
def test_revert_refuses_hand_edited_files(epub2md_dir: Path) -> None:
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
pipeline.scan(source, ws, quiet=True)
_inject(ws, {"0001-inline.jpg": r"L=\{-\sqrt{2};\sqrt{2}\}"})
pipeline.verify_all(source, ws, preset="cm", workers=1, quiet=True)
pipeline.apply(source, ws, quiet=True)
md = epub2md_dir / "chapters" / "001-kapitel.md"
md.write_text(md.read_text(encoding="utf-8") + "\nHandnotiz\n", encoding="utf-8")
assert pipeline.revert(source, ws, quiet=True)["skipped"] == 1
assert "Handnotiz" in md.read_text(encoding="utf-8")
def test_cli_errors(tmp_path: Path, capsys) -> None:
assert main(["status", str(tmp_path / "fehlt")]) == 1
(tmp_path / "leer").mkdir()
assert main(["scan", str(tmp_path / "leer")]) == 2
assert "weder eine epub2md-Ausgabe" in capsys.readouterr().err
def test_engine_answer_parsing() -> None:
assert parse_answer('{"is_formula": true, "latex": "x^2"}') == ("x^2", True)
assert parse_answer('```json\n{"is_formula": true, "latex": "\\frac{a}{b}"}\n```') == ("\\frac{a}{b}", True)
assert parse_answer('{"is_formula": true, "latex": "\\sqrt{2}"}') == ("\\sqrt{2}", True) # unescaped backslash
assert parse_answer('{"is_formula": false, "latex": ""}') == (None, False)
assert parse_answer("x_1 + x_2") == ("x_1 + x_2", None)
def test_prepare_image_enlarges_small_formulas(epub2md_dir: Path) -> None:
import io
from PIL import Image
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
assert im.height >= 96 and im.mode == "L"