161 lines
8.6 KiB
Python
161 lines
8.6 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""Result page of a whole book: every formula with original, set LaTeX, status and reason.
|
|||
|
|
|
|||
|
|
python3 tools/buch_bericht.py AUSGABE [--pdf BUCH.pdf] [--out ORDNER]
|
|||
|
|
|
|||
|
|
Reads the workspace of a finished `run` (nothing is recomputed, no model is asked) and writes
|
|||
|
|
<workspace>/bericht/index.html with images in bericht/img/ (default). The page lives next to the
|
|||
|
|
book because it shows excerpts of it. Filter buttons: all, automatically replaced, review, image.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import html
|
|||
|
|
import io
|
|||
|
|
import re
|
|||
|
|
import statistics
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
from PIL import Image
|
|||
|
|
|
|||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|||
|
|
|
|||
|
|
from formula_img2latex.pipeline import _ref_for, latest_by_engine, open_all # noqa: E402
|
|||
|
|
from formula_img2latex.render import render_many # noqa: E402
|
|||
|
|
from formula_img2latex.tex import strip_delimiters # noqa: E402
|
|||
|
|
|
|||
|
|
LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"}
|
|||
|
|
STATUS = {"auto_accepted": ("automatisch ersetzt", "good"), "accepted_by_human": ("von Hand übernommen", "good"),
|
|||
|
|
"needs_review": ("Review", "rev"), "not_formula": ("keine Formel", "img"),
|
|||
|
|
"rejected_keep_image": ("bleibt Bild", "img"), "kept_by_human": ("bleibt Bild (Hand)", "img"),
|
|||
|
|
"render_failed": ("nicht setzbar", "img"), "pending": ("nicht geprüft", "img")}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def page_key(f) -> tuple[int, int]:
|
|||
|
|
m = re.search(r"Seite (\d+), Box (\d+)", f.source_name or "")
|
|||
|
|
return (int(m.group(1)), int(m.group(2))) if m else (10**6, 0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_png(img: Image.Image, path: Path) -> None:
|
|||
|
|
img.save(path, "PNG", optimize=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def original_png(data: bytes, max_h: int = 110, max_w: int = 900) -> Image.Image:
|
|||
|
|
im = Image.open(io.BytesIO(data))
|
|||
|
|
if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info:
|
|||
|
|
im = Image.alpha_composite(Image.new("RGBA", im.size, "white"), im.convert("RGBA"))
|
|||
|
|
im = im.convert("L")
|
|||
|
|
f = min(1.0, max_h / max(im.height, 1), max_w / max(im.width, 1))
|
|||
|
|
if f < 1.0:
|
|||
|
|
im = im.resize((max(1, round(im.width * f)), max(1, round(im.height * f))), Image.LANCZOS)
|
|||
|
|
return im
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|||
|
|
ap.add_argument("source", type=Path)
|
|||
|
|
ap.add_argument("--pdf", type=Path, default=None)
|
|||
|
|
ap.add_argument("--out", type=Path, default=None)
|
|||
|
|
ap.add_argument("--titel", default=None)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
source, ws = open_all(args.source, None, pdf=args.pdf)
|
|||
|
|
out = args.out or ws.root / "bericht"
|
|||
|
|
(out / "img").mkdir(parents=True, exist_ok=True)
|
|||
|
|
formulas = sorted(ws.all(), key=page_key)
|
|||
|
|
preset = ws.settings.get("preset") or "cm"
|
|||
|
|
|
|||
|
|
# the LaTeX shown: the replacement if there is one, else pix2text's reading (rule p2t)
|
|||
|
|
shown = []
|
|||
|
|
for f in formulas:
|
|||
|
|
tex = f.final_tex
|
|||
|
|
if not tex:
|
|||
|
|
p = latest_by_engine(f).get("p2t")
|
|||
|
|
tex = p.get("tex") if p else None
|
|||
|
|
shown.append(strip_delimiters(tex) if tex else None)
|
|||
|
|
rendered = render_many([(t or "\\text{–}", f.display) for t, f in zip(shown, formulas)], preset)
|
|||
|
|
|
|||
|
|
counts: dict[str, int] = {}
|
|||
|
|
secs: dict[str, list[float]] = {}
|
|||
|
|
cards = []
|
|||
|
|
for n, (f, tex, rr) in enumerate(zip(formulas, shown, rendered), 1):
|
|||
|
|
counts[f.status] = counts.get(f.status, 0) + 1
|
|||
|
|
latest = latest_by_engine(f)
|
|||
|
|
for e, r in latest.items():
|
|||
|
|
if r.get("seconds"):
|
|||
|
|
secs.setdefault(e, []).append(float(r["seconds"]))
|
|||
|
|
try:
|
|||
|
|
o = out / "img" / f"{f.id}-o.png"
|
|||
|
|
if not o.exists(): # the original never changes; cutting it from the PDF is the slow part
|
|||
|
|
save_png(original_png(source.original_bytes(_ref_for(source, f))), o)
|
|||
|
|
orig = f"<img loading=lazy src='img/{f.id}-o.png'>"
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
orig = "<i>Original nicht lesbar</i>"
|
|||
|
|
if tex and rr.ok:
|
|||
|
|
g = (rr.rasterize(1.6) * 255).astype(np.uint8)
|
|||
|
|
save_png(Image.fromarray(255 - g), out / "img" / f"{f.id}-l.png")
|
|||
|
|
latex = f"<img loading=lazy src='img/{f.id}-l.png'>"
|
|||
|
|
else:
|
|||
|
|
latex = "<i>–</i>"
|
|||
|
|
label, cls = STATUS.get(f.status, (f.status, "img"))
|
|||
|
|
used = "ersetzt durch" if f.final_tex else "Lesart pix2text"
|
|||
|
|
reads = "".join(f"<tr><th>{LABEL.get(e, e)}</th><td><code>{html.escape(r.get('tex') or '–')}</code></td></tr>"
|
|||
|
|
for e, r in sorted(latest.items(), key=lambda kv: ["p2t", "local", "local2", "cloud"].index(kv[0])
|
|||
|
|
if kv[0] in ("p2t", "local", "local2", "cloud") else 9))
|
|||
|
|
occ = f.occurrences[0] if f.occurrences else {}
|
|||
|
|
cards.append(
|
|||
|
|
f"<section class='{cls}' data-s='{cls}'><div class=h><b>{n}</b> · {html.escape(f.source_name)} · "
|
|||
|
|
f"Zeile {occ.get('line', '?')} · <span class=b>{label}</span></div>"
|
|||
|
|
f"<div class=why>{html.escape(f.reason or '')}</div>"
|
|||
|
|
f"<div class=row><div><small>Original (PDF)</small>{orig}</div><div><small>{used}</small>{latex}</div></div>"
|
|||
|
|
f"<code class=tex>{html.escape(tex or '–')}</code>"
|
|||
|
|
f"<details><summary>alle Lesarten</summary><table>{reads}</table></details></section>")
|
|||
|
|
|
|||
|
|
total = len(formulas)
|
|||
|
|
good = counts.get("auto_accepted", 0) + counts.get("accepted_by_human", 0)
|
|||
|
|
rev = counts.get("needs_review", 0)
|
|||
|
|
img = total - good - rev
|
|||
|
|
time_rows = "".join(f"<tr><th>Ø {LABEL.get(e, e)}</th><td>{statistics.mean(v):.2f} s ({len(v)} Formeln)</td></tr>"
|
|||
|
|
for e, v in sorted(secs.items()))
|
|||
|
|
status_rows = "".join(f"<tr><th>{STATUS.get(k, (k,))[0]}</th><td>{v} ({v / max(total, 1):.0%})</td></tr>"
|
|||
|
|
for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
|
|||
|
|
titel = args.titel or f"Formeln: {source.path.name}"
|
|||
|
|
page = (
|
|||
|
|
f"<!doctype html><meta charset=utf-8><title>{html.escape(titel)}</title><style>"
|
|||
|
|
":root{--bg:#f6f6f3;--card:#fff;--ink:#222;--mut:#666;--line:#ddd}"
|
|||
|
|
"body{font:14px system-ui,sans-serif;margin:0;padding:16px;background:var(--bg);color:var(--ink)}"
|
|||
|
|
"h1{font-size:20px}table.sum{border-collapse:collapse;background:var(--card);margin:8px 0}"
|
|||
|
|
"table.sum th,table.sum td{border:1px solid var(--line);padding:4px 10px;text-align:left}"
|
|||
|
|
".bar{position:sticky;top:0;background:var(--bg);padding:8px 0;z-index:1}"
|
|||
|
|
"button{font:inherit;padding:4px 10px;margin-right:6px;border:1px solid #bbb;border-radius:4px;background:#fff;cursor:pointer}"
|
|||
|
|
"button.on{background:#222;color:#fff}"
|
|||
|
|
"section{background:var(--card);border:1px solid var(--line);border-left:6px solid #999;border-radius:6px;"
|
|||
|
|
"padding:6px 10px;margin:8px 0}section.good{border-left-color:#1f7a3a}section.rev{border-left-color:#c88a00}"
|
|||
|
|
"section.img{border-left-color:#888}.h{font-size:13px}.b{font-weight:600}.good .b{color:#1f7a3a}"
|
|||
|
|
".rev .b{color:#a15c00}.why{font-size:12px;color:var(--mut);margin:2px 0 4px}"
|
|||
|
|
".row{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start}.row>div{display:flex;flex-direction:column;gap:2px}"
|
|||
|
|
".row img{max-width:min(900px,100%);height:auto}code{font-size:12px}code.tex{display:block;margin:4px 0;"
|
|||
|
|
"overflow-x:auto;white-space:pre-wrap;word-break:break-all}details{font-size:12px}"
|
|||
|
|
"details th{text-align:left;font-weight:400;color:var(--mut);padding-right:8px;vertical-align:top}</style>"
|
|||
|
|
f"<h1>{html.escape(titel)}</h1>"
|
|||
|
|
f"<p>{total} Formelbilder (abgesetzte Formeln aus der PDF). Regel <code>p2t</code>: pix2text liest, Qwen "
|
|||
|
|
"bestätigt, Gemma nur bei Uneinigkeit, dazu Pandoc und Bild-Veto. Links das Original aus der PDF, rechts das "
|
|||
|
|
"gesetzte LaTeX.</p>"
|
|||
|
|
f"<table class=sum>{status_rows}{time_rows}</table>"
|
|||
|
|
"<div class=bar><button class=on data-f=all>alle</button>"
|
|||
|
|
f"<button data-f=good>ersetzt ({good})</button><button data-f=rev>Review ({rev})</button>"
|
|||
|
|
f"<button data-f=img>Bild/keine Formel ({img})</button></div>"
|
|||
|
|
+ "".join(cards)
|
|||
|
|
+ "<script>document.querySelectorAll('button[data-f]').forEach(function(b){b.onclick=function(){"
|
|||
|
|
"document.querySelectorAll('button[data-f]').forEach(function(x){x.classList.toggle('on',x===b);});"
|
|||
|
|
"var f=b.dataset.f;document.querySelectorAll('section').forEach(function(s){"
|
|||
|
|
"s.hidden=!(f==='all'||s.dataset.s===f);});};});</script>")
|
|||
|
|
(out / "index.html").write_text(page, encoding="utf-8")
|
|||
|
|
print(out / "index.html")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|