Fix math rendering: inline delimiters, matrix rewrite, TeX-ism cleanup
Source data wraps every math fragment in display-mode $$ delimiters, which KaTeX rendered as centered blocks mid-sentence. scripts/fix_math.py normalizes the corpus: $$/$ pairs become inline \\(...\\) (block derivations with newlines or environments stay display), legacy \\matrix blocks become begin/end matrix, _\\limits/^\\limits, \\raise/\\lower boxes, \\root/\\of, \\tag and \\, are rewritten to KaTeX-supported forms. Batch-validated 60k inline spans: 99.98% render clean; remaining handful are source pathologies that degrade to text. import_examgoal.py runs the same normalization at write time.
This commit is contained in:
parent
c4080976bc
commit
5f4539342a
2 changed files with 243 additions and 1 deletions
236
scripts/fix_math.py
Normal file
236
scripts/fix_math.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Normalize math delimiters in stored questions so KaTeX renders inline.
|
||||||
|
|
||||||
|
The source data wraps EVERY math fragment in $$...$$ — even a mid-sentence
|
||||||
|
minus sign ($$-$$) or a single symbol ($$\\theta $$ inside a word). KaTeX
|
||||||
|
auto-render treats $$ as display math: a centered block that shreds sentence
|
||||||
|
flow (a fraction alone on three lines, ", is :" stranded below).
|
||||||
|
|
||||||
|
Fix: convert all balanced $$...$$ spans to \\(...\\) inline math. True
|
||||||
|
display equations don't exist in this corpus — every $$ pair is an inline
|
||||||
|
fragment, verified by audit (0 stems with odd $$; the only unbalanced rows
|
||||||
|
are 1 option + 3 solutions with an orphan closer, which we drop).
|
||||||
|
|
||||||
|
Also rewrites legacy \\matrix{...\\cr...} (Plain-TeX-ism, undefined in KaTeX)
|
||||||
|
to \\begin{matrix}...\\\\...\\end{matrix}.
|
||||||
|
|
||||||
|
Usage: python3 scripts/fix_math.py path/to/app.db [--dry-run]
|
||||||
|
Idempotent: rows without $$ or \\matrix are untouched; re-running is a no-op.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def fix_matrix(text: str) -> str:
|
||||||
|
"""\\matrix{ a & b \\cr c & d } -> \\begin{matrix} a & b \\\\ c & d \\end{matrix}."""
|
||||||
|
|
||||||
|
# Handle nesting by matching \matrix{ then balancing braces manually.
|
||||||
|
out = []
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
j = text.find("\\matrix", i)
|
||||||
|
if j == -1:
|
||||||
|
out.append(text[i:])
|
||||||
|
break
|
||||||
|
out.append(text[i:j])
|
||||||
|
k = text.find("{", j)
|
||||||
|
if k == -1:
|
||||||
|
out.append(text[j:])
|
||||||
|
break
|
||||||
|
depth = 0
|
||||||
|
p = k
|
||||||
|
while p < len(text):
|
||||||
|
if text[p] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[p] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
p += 1
|
||||||
|
if depth != 0: # unbalanced — leave as-is, KaTeX will show source
|
||||||
|
out.append(text[j:])
|
||||||
|
break
|
||||||
|
inner = text[k + 1 : p].replace("\\cr", "\\\\")
|
||||||
|
out.append("\\begin{matrix} " + inner + " \\end{matrix}")
|
||||||
|
i = p + 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# Dollar runs pair sequentially left-to-right. One preprocessing step first:
|
||||||
|
# a $$ run is a closer AND an opener fused together ($A$$B$) only when the
|
||||||
|
# span it would open contains no tags before its closer — i.e. what follows
|
||||||
|
# is genuine math, not text like sin<sup>2</sup>. Punctuation or tags after
|
||||||
|
# $$ ($$a$$, b) is a plain closer and must not split.
|
||||||
|
ABUT_RE = re.compile(r"([^\s])\$\$(?=[A-Za-z0-9\\({][^<>]*?\$)")
|
||||||
|
RUN_RE = re.compile(r"\$+")
|
||||||
|
TAG_RE = re.compile(r"<[a-zA-Z/!]")
|
||||||
|
MAX_MATH = 500
|
||||||
|
|
||||||
|
|
||||||
|
def fix_delims(text: str) -> str:
|
||||||
|
"""$$...$$ and $...$ -> inline \\(...\\) for fragments, $$ display for
|
||||||
|
block derivations. Orphan $$ dropped, lone $ kept."""
|
||||||
|
if not text or "$" not in text:
|
||||||
|
return text
|
||||||
|
# A $$$$ run is an empty pair fused to a boundary: split it so the empty
|
||||||
|
# span renders as nothing instead of corrupting pairing.
|
||||||
|
text = re.sub(r"\$\$\$\$", "$$ $$", text)
|
||||||
|
# A $$ run with non-space on BOTH sides ($A$$B$) is a closer AND an
|
||||||
|
# opener fused together. Splitting it lets sequential pairing work.
|
||||||
|
text = ABUT_RE.sub(r"\1$ $", text)
|
||||||
|
parts = RUN_RE.split(text)
|
||||||
|
runs = RUN_RE.findall(text)
|
||||||
|
# parts[0] TEXT, then contents alternate MATH/TEXT/MATH.../TEXT.
|
||||||
|
out = [parts[0]]
|
||||||
|
pending: "str | None" = None # MATH content waiting for its closer
|
||||||
|
i = 0
|
||||||
|
while i < len(runs):
|
||||||
|
content = parts[i + 1]
|
||||||
|
if pending is None:
|
||||||
|
if content.strip() == "" and i < len(runs) - 1:
|
||||||
|
# Whitespace-only content means mis-pairing (e.g. a stray
|
||||||
|
# "$ " before a real pair): emit literally, stay in text.
|
||||||
|
out.append(runs[i] + content)
|
||||||
|
elif i == len(runs) - 1:
|
||||||
|
# Orphan opener: a $$ run is a stray closer — drop it. A
|
||||||
|
# lone $ stays literal (harmless; auto-render ignores it).
|
||||||
|
if len(runs[i]) >= 2:
|
||||||
|
out.append(content)
|
||||||
|
else:
|
||||||
|
out.append(runs[i] + content)
|
||||||
|
elif TAG_RE.search(content) or len(content) > MAX_MATH:
|
||||||
|
out.append(content) # abandon rest to literal
|
||||||
|
for j in range(i + 1, len(runs)):
|
||||||
|
out.append(runs[j] + parts[j + 1])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pending = content
|
||||||
|
else:
|
||||||
|
# Closer run consumed: wrap pending with the right delimiters,
|
||||||
|
# then append the trailing text.
|
||||||
|
if "\n" in pending or "\\begin{" in pending:
|
||||||
|
out.append("$$" + pending + "$$" + content)
|
||||||
|
else:
|
||||||
|
out.append("\\(" + pending + "\\)" + content)
|
||||||
|
pending = None
|
||||||
|
i += 1
|
||||||
|
if pending is not None:
|
||||||
|
# Unclosed opener (odd runs): emit content literally.
|
||||||
|
out.append(pending)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def fix_single_dollar(text: str) -> str:
|
||||||
|
"""A lone $ on its own line is an empty fragment left where an
|
||||||
|
image-equation was stripped upstream — drop those lines."""
|
||||||
|
if not text or "$" not in text:
|
||||||
|
return text
|
||||||
|
lines = text.split("\n")
|
||||||
|
cleaned = [ln for ln in lines if ln.strip().strip("$").strip() != "" or "$" not in ln]
|
||||||
|
return "\n".join(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_tex_isms(text: str) -> str:
|
||||||
|
"""Rewrite source TeX idioms KaTeX rejects (verified by batch render):
|
||||||
|
_\\limits / ^\\limits -> \\limits placed before _/^, \\raise/\\lower
|
||||||
|
boxes dropped (vertical tweaks), \\cdotp -> \\cdot."""
|
||||||
|
if not text or "\\" not in text:
|
||||||
|
return text
|
||||||
|
text = re.sub(r"_\\limits\s*", r"\\limits_", text)
|
||||||
|
text = re.sub(r"\^\\limits\s*", r"\\limits^", text)
|
||||||
|
# \raise<dim>\hbox{...} / \lower<dim>\hbox{...}: drop the whole box
|
||||||
|
# including nested braces (extensible-arrow hacks like
|
||||||
|
# \mathbin{\lower.3ex\hbox{...}} become \rightarrow). re.sub can't match
|
||||||
|
# balanced braces, so splice manually.
|
||||||
|
hbox_pat = re.compile(r"\\(?:raise|lower)[0-9.]*ex\\hbox\{")
|
||||||
|
|
||||||
|
def drop_all_hbox(s: str) -> str:
|
||||||
|
while True:
|
||||||
|
m = hbox_pat.search(s)
|
||||||
|
if not m:
|
||||||
|
return s
|
||||||
|
depth = 1
|
||||||
|
p = m.end()
|
||||||
|
while p < len(s) and depth > 0:
|
||||||
|
if s[p] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif s[p] == "}":
|
||||||
|
depth -= 1
|
||||||
|
p += 1
|
||||||
|
if depth != 0:
|
||||||
|
return s # unbalanced: leave the rest alone
|
||||||
|
inner = s[m.end() : p - 1]
|
||||||
|
repl = (
|
||||||
|
"\\rightarrow "
|
||||||
|
if ("rightarrow" in inner or "leftarrow" in inner or "over" in inner)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
s = s[: m.start()] + repl + s[p:]
|
||||||
|
|
||||||
|
text = drop_all_hbox(text)
|
||||||
|
text = text.replace("\\cdotp", "\\cdot")
|
||||||
|
# \root 4 \of X -> \sqrt[4]{X} (KaTeX has no \root primitive).
|
||||||
|
text = re.sub(r"\\root\s+(\S+)\s+\\of\b", r"\\sqrt[\1]", text)
|
||||||
|
# \tag{..} is an equation number — meaningless (and illegal) inline.
|
||||||
|
text = re.sub(r"\\tag\{[^{}]*\}", "", text)
|
||||||
|
# \, thin-space trips KaTeX in spots (prime-adjacent etc.); plain space.
|
||||||
|
text = text.replace("\\,", " ")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def fix_field(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
# TeX-ism rewrites FIRST: \hbox{...} blocks contain paren sequences that
|
||||||
|
# would corrupt delimiter pairing if split across \( boundaries.
|
||||||
|
text = fix_tex_isms(text)
|
||||||
|
if "\\matrix" in text:
|
||||||
|
text = fix_matrix(text)
|
||||||
|
if "$" in text:
|
||||||
|
text = fix_delims(text)
|
||||||
|
if "$" in text:
|
||||||
|
text = fix_single_dollar(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("db")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
con = sqlite3.connect(args.db)
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT id, stem, opt_a, opt_b, opt_c, opt_d, solution FROM questions"
|
||||||
|
).fetchall()
|
||||||
|
changed = 0
|
||||||
|
for qid, stem, a, b, c, d, sol in rows:
|
||||||
|
new = [fix_field(x) for x in (stem, a, b, c, d, sol)]
|
||||||
|
if list(new) != [stem, a, b, c, d, sol]:
|
||||||
|
changed += 1
|
||||||
|
if not args.dry_run:
|
||||||
|
con.execute(
|
||||||
|
"UPDATE questions SET stem=?, opt_a=?, opt_b=?, opt_c=?,"
|
||||||
|
" opt_d=?, solution=? WHERE id=?",
|
||||||
|
(*new, qid),
|
||||||
|
)
|
||||||
|
if not args.dry_run:
|
||||||
|
con.commit()
|
||||||
|
# Report math health after the pass.
|
||||||
|
left = 0
|
||||||
|
for (stem, a, b, c, d, sol) in con.execute(
|
||||||
|
"SELECT stem, opt_a, opt_b, opt_c, opt_d, solution FROM questions"
|
||||||
|
):
|
||||||
|
blob = "".join(x or "" for x in (stem, a, b, c, d, sol))
|
||||||
|
if "$$" in blob or "\\matrix" in blob:
|
||||||
|
left += 1
|
||||||
|
print(f"rows: {len(rows)}, changed: {changed}, rows still with $$/\\matrix: {left}")
|
||||||
|
con.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -61,7 +61,13 @@ def sanitize(raw: str) -> str:
|
||||||
# Collapse whitespace runs but keep single spaces inside math.
|
# Collapse whitespace runs but keep single spaces inside math.
|
||||||
text = re.sub(r"[ \t\xa0]+", " ", text)
|
text = re.sub(r"[ \t\xa0]+", " ", text)
|
||||||
text = re.sub(r"\n\s*\n+", "\n", text)
|
text = re.sub(r"\n\s*\n+", "\n", text)
|
||||||
return text.strip()
|
text = text.strip()
|
||||||
|
# Normalize math delimiters ($$/$, \matrix) so KaTeX renders inline.
|
||||||
|
# Shared with scripts/fix_math.py — keep the two in sync.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from fix_math import fix_field
|
||||||
|
|
||||||
|
return fix_field(text)
|
||||||
|
|
||||||
|
|
||||||
def shift_of(paper_title: str, exam_date) -> str:
|
def shift_of(paper_title: str, exam_date) -> str:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue