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:
commit
eb72d0b0f2
3 changed files with 4363 additions and 0 deletions
68
tests/conftest.py
Normal file
68
tests/conftest.py
Normal 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 .\n\n"
|
||||
"Allgemein gilt\n\n\n\n"
|
||||
"```\n\n```\n\n"
|
||||
"\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
109
tests/test_core.py
Normal 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
108
tests/test_pipeline.py
Normal 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\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"
|
||||
Loading…
Reference in a new issue