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>
108 lines
5 KiB
Python
108 lines
5 KiB
Python
"""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"
|