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

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