[NOTICKET] fix(knowledge-parsing): render_latex was deleting division (X21)
Browse filesRifqi's three faults, all reproduced against the document's own 11 equations
and all fixed. Two of his numbers were off, and the corrections matter:
- It hit 4 of 11 equations, not 2 — PA, PA_ij, UA_ij and PTY_ij. Not just
PA and UA: every composite form was affected too.
- The nested braces come from mathrm{} and sum _{ij}, NOT from text{}, which
this standard never uses. A fix written against text{} would have repaired
the synthetic example and no real equation.
(C), the serious one: the frac rewrite needed a brace-free argument, could not
match a nested numerator, and the unmatched frac then fell through to the
generic "drop leftover commands" pass. The division was deleted, leaving a
formula that reads as a product and states something the document does not.
The ordering decision this needed: braced wrappers are unwrapped BEFORE frac,
and the unwrap consumes its braces. The old pass removed only the command name
and left { } behind — those leftover braces were what blocked frac all along.
frac now uses a balanced-brace scanner; a regex cannot express nesting and
fails silently rather than raising.
(A) and (B) were run-boundary faults: boundaries are now word-character
classes, and subscript contents are joined at fold time.
Division lost 4 -> 0. Six regression cases unchanged.
Still open, deliberately, because it changes interpretation and not mechanics:
after MinerU spaces every character, a multiplication "x" cannot be told apart
from the letter. The standard's flagship formula therefore still renders as
MOHHxQtyxPAxUAxPty, costing the term filter five mentions. The retraction in
the previous diagnosis claimed that formula "renders perfectly" — it does not;
its raw source carries a literal "x", no times and no mathrm.
Docs corrected to match, including escaping damage that had turned frac and
text into control characters in the diagnosis itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- KNOWLEDGE_PIPELINE_CALIBRATION.md +67 -27
- KNOWLEDGE_PIPELINE_TODO.md +2 -1
- src/knowledge_parsing/render.py +95 -13
|
@@ -356,44 +356,84 @@ Fix direction: `rule.txt` should exclude bare equations explicitly ("an equation
|
|
| 356 |
branch's job; a rule is prose that constrains behaviour"). That is a prompt change and therefore owes
|
| 357 |
an eval run per CLAUDE.md §7B — **not** to be done on vibes.
|
| 358 |
|
| 359 |
-
### Cause 3 — three distinct `render_latex` faults (
|
| 360 |
-
|
| 361 |
-
> **
|
| 362 |
-
>
|
| 363 |
-
>
|
| 364 |
-
>
|
| 365 |
-
>
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
`
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
```
|
| 372 |
'I N P R H o u r s' -> 'INPRHours' # correct, no brackets
|
| 373 |
'(I N P R H o u r s)' -> '(I NPRHour s)' # first and last stranded
|
| 374 |
```
|
| 375 |
|
| 376 |
-
**(B) The subscript fold
|
| 377 |
-
|
| 378 |
-
instead of two words.
|
| 379 |
|
| 380 |
-
**(C) The serious one — fraction structure
|
| 381 |
|
| 382 |
```
|
| 383 |
-
\mathrm{
|
| 384 |
-
|
|
|
|
| 385 |
```
|
| 386 |
|
| 387 |
-
The `
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
-
|
| 393 |
-
`
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
Impact
|
| 396 |
-
|
| 397 |
-
formula can be split away from itself.
|
| 398 |
|
| 399 |
-
**Owner: Sofhia** — `src/knowledge_parsing/render.py`.
|
|
|
|
| 356 |
branch's job; a rule is prose that constrains behaviour"). That is a prompt change and therefore owes
|
| 357 |
an eval run per CLAUDE.md §7B — **not** to be done on vibes.
|
| 358 |
|
| 359 |
+
### Cause 3 — three distinct `render_latex` faults (FIXED 2026-08-24)
|
| 360 |
+
|
| 361 |
+
> **Two corrections, both from re-measuring against the document's own 11 equations.**
|
| 362 |
+
>
|
| 363 |
+
> An earlier version of this section said `render_latex` "eats the first character". That was
|
| 364 |
+
> retracted — the repro was mangled by shell escaping, not by the code.
|
| 365 |
+
>
|
| 366 |
+
> The retraction then over-corrected. It claimed `Production = MOHH × Qty × PA × UA × Pty`
|
| 367 |
+
> "renders perfectly". **It does not.** The document's raw `content_list.json` contains no
|
| 368 |
+
> `\times` and no `\mathrm` — just a literal `x`, spaced like every other character:
|
| 369 |
+
>
|
| 370 |
+
> ```
|
| 371 |
+
> 'P r o d u c t i o n = M O H H x Q t y x P A x U A x P t y'
|
| 372 |
+
> -> 'Production = MOHHxQtyxPAxUAxPty'
|
| 373 |
+
> ```
|
| 374 |
+
>
|
| 375 |
+
> MOHH, Qty, PA, UA and Pty — the five terms the glossary is built around — fuse into one
|
| 376 |
+
> token. See *Still open* below.
|
| 377 |
+
|
| 378 |
+
**(A) A spaced run touching a bracket is only half-collapsed.** `_SPACED_RUN` was bounded by
|
| 379 |
+
`(?<!\S)` / `(?!\S)`, so a run could not match if it touched `(` or `)`. The `\frac{a}{b}` →
|
| 380 |
+
`(a) / (b)` rewrite inserts exactly those brackets, stranding a character at **each** end:
|
| 381 |
|
| 382 |
```
|
| 383 |
'I N P R H o u r s' -> 'INPRHours' # correct, no brackets
|
| 384 |
'(I N P R H o u r s)' -> '(I NPRHour s)' # first and last stranded
|
| 385 |
```
|
| 386 |
|
| 387 |
+
**(B) The subscript fold ran before the collapse and split the run.** `Q t y _ {a c t i v i t y}`
|
| 388 |
+
became `Qt y_a ctivity` — the `_` glued mid-run, so the collapse saw broken fragments.
|
|
|
|
| 389 |
|
| 390 |
+
**(C) The serious one — fraction structure was silently dropped.**
|
| 391 |
|
| 392 |
```
|
| 393 |
+
\mathrm { P A } = { \frac { \mathrm { T o t a l } ~ H o u r s - B r e a k d o w n }
|
| 394 |
+
{ \mathrm { T o t a l } ~ H o u r s } } \mathrm { x } 1 0 0 \%
|
| 395 |
+
-> 'PA = Total Hours - BreakdownTotal Hoursx 100 %'
|
| 396 |
```
|
| 397 |
|
| 398 |
+
The `\frac` rewrite needed `\{([^{}]*)\}`, which cannot match a numerator containing nested
|
| 399 |
+
braces. The unmatched `\frac` then fell through to the generic "drop any leftover command" pass,
|
| 400 |
+
so **the division was deleted rather than rendered.** This is not a spacing blemish — the formula
|
| 401 |
+
reads as a product and states something the document does not.
|
| 402 |
+
|
| 403 |
+
> **The nesting comes from `\mathrm{…}` and `\sum _{ij}`, not `\text{…}`.** The BUMA standard
|
| 404 |
+
> never uses `\text{}`. A fix written against `\text{}` would repair a synthetic example and no
|
| 405 |
+
> real equation.
|
| 406 |
+
|
| 407 |
+
**Measured tally, 11 equations:**
|
| 408 |
+
|
| 409 |
+
| | Before | After |
|
| 410 |
+
|---|---|---|
|
| 411 |
+
| Division lost | **4** — eq 6, 7, 9, 11 (`PA`, `PA_ij`, `UA_ij`, `PTY_ij`) | **0** |
|
| 412 |
+
| Stranded characters | 4 | 0 |
|
| 413 |
+
| Clean | 5 | 9 + 2 carrying only the document's own literal `x` |
|
| 414 |
+
|
| 415 |
+
Not two equations and not only PA/UA: **every composite (`_ij`) form was affected too.**
|
| 416 |
+
|
| 417 |
+
**The fix** (`src/knowledge_parsing/render.py`):
|
| 418 |
+
|
| 419 |
+
1. **Braced wrappers are unwrapped before `\frac`, consuming their braces.** The old `_WRAPPERS`
|
| 420 |
+
deleted only the command name and left `{ }` behind — those leftover braces were what stopped
|
| 421 |
+
`\frac` from matching. This is the ordering decision the diagnosis asked for.
|
| 422 |
+
2. **`\frac` uses a balanced-brace scanner**, not a regex. `[^{}]*` can never handle a nested
|
| 423 |
+
argument, and it fails silently rather than raising.
|
| 424 |
+
3. **Run boundaries are word-character classes**, and subscript contents are joined at fold time
|
| 425 |
+
(`_ { i j }` → `_ij`).
|
| 426 |
+
|
| 427 |
+
Six regression cases hold unchanged: `C_p`, `C 5`, `C_p × T`, `\frac{a}{b}`, `Purchasing costs`,
|
| 428 |
+
and a bare spaced run.
|
| 429 |
|
| 430 |
+
**Still open — a decision, not a defect.** After MinerU spaces every character, a multiplication
|
| 431 |
+
`x` is indistinguishable from the letter `x`: no `\times`, no `\mathrm`, no signal at all. Treating
|
| 432 |
+
it as an operator is right for engineering formulas and wrong for a variable named `x`. The
|
| 433 |
+
`_SYMBOLS` comment shows the author already weighed this trade for `\times`. Left unfixed
|
| 434 |
+
deliberately, because it changes interpretation rather than mechanics.
|
| 435 |
|
| 436 |
+
Impact of what remains: a term occurring only inside `Production = …` cannot be separated from its
|
| 437 |
+
neighbours, which costs the term filter five mentions on the standard's flagship formula.
|
|
|
|
| 438 |
|
| 439 |
+
**Owner: Sofhia** — `src/knowledge_parsing/render.py`.
|
|
@@ -169,7 +169,8 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
|
|
| 169 |
| **X18** | Full-document run through both halves | Rifqi | ✅ | **Done 2026-08-24** → `eval/knowledge/results/v2_full_document_2026-08-24_093051.json`. Parsing module built the artifact (31 chunks), extraction ran all four branches: 77 calls, 80% cache hit. **E3 = 0.90, PASS** — the prototype's failing experiment (0.75 vs a 0.80 line) now clears it, on a 10-entry scoreable base |
|
| 170 |
| **X19** | Rule-branch recall regression | Rifqi | 🔎 | **Diagnosed 2026-08-24 — calibration §12.** Three causes: (a) 2 of 3 losses are a *scoring artifact* (gold written `QtyA`, rendered output `Qty_A`; `_` is a word char so the substring match fails) — true comparison is 4/15 → 3/15; (b) the one real loss is rendered formulas competing with prose — 4 of 8 v2 rule statements are bare equations vs 0 of 7 in the prototype, and the formula branch already captures them; (c) a `render_latex` bug (X21) |
|
| 171 |
| **X20** | Exclude bare equations in `rule.txt` | Rifqi | ⬜ | The fix for X19(b). A prompt change → owes an eval run per §7B; do not ship on vibes. The formula branch already covers equations, so this is duplicated spend as well as a miss |
|
| 172 |
-
| **X21** | `render_latex` — three faults, one serious | Sofhia |
|
|
|
|
| 173 |
| **X22** | Gold/scorer notation decision | Rifqi | ⬜ | Whether to normalise `_` in the scorer or rewrite the gold's `statement_contains`. Either edits the frozen measuring instrument, so it needs a deliberate call rather than a silent fix |
|
| 174 |
| **X13** | Conflict detection | 🔎 | ✅ | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** — one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
|
| 175 |
| **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
|
|
|
|
| 169 |
| **X18** | Full-document run through both halves | Rifqi | ✅ | **Done 2026-08-24** → `eval/knowledge/results/v2_full_document_2026-08-24_093051.json`. Parsing module built the artifact (31 chunks), extraction ran all four branches: 77 calls, 80% cache hit. **E3 = 0.90, PASS** — the prototype's failing experiment (0.75 vs a 0.80 line) now clears it, on a 10-entry scoreable base |
|
| 170 |
| **X19** | Rule-branch recall regression | Rifqi | 🔎 | **Diagnosed 2026-08-24 — calibration §12.** Three causes: (a) 2 of 3 losses are a *scoring artifact* (gold written `QtyA`, rendered output `Qty_A`; `_` is a word char so the substring match fails) — true comparison is 4/15 → 3/15; (b) the one real loss is rendered formulas competing with prose — 4 of 8 v2 rule statements are bare equations vs 0 of 7 in the prototype, and the formula branch already captures them; (c) a `render_latex` bug (X21) |
|
| 171 |
| **X20** | Exclude bare equations in `rule.txt` | Rifqi | ⬜ | The fix for X19(b). A prompt change → owes an eval run per §7B; do not ship on vibes. The formula branch already covers equations, so this is duplicated spend as well as a miss |
|
| 172 |
+
| **X21** | `render_latex` — three faults, one serious | Sofhia | ✅ | **Found and FIXED 2026-08-24.** (A) a spaced run touching a `(` inserted by the `\frac` rewrite was only half-collapsed → `(I NPRHour s)`; (B) the subscript fold split the run → `Qt y_a ctivity`; **(C) a `\frac` whose numerator contains nested braces lost its division entirely** → `PA = Total Hours - BreakdownTotal Hours`, which states something the document does not. **Two corrections on re-measurement:** it hit **4 of 11 equations, not 2** — `PA`, `PA_ij`, `UA_ij`, `PTY_ij`, so every composite form as well; and the nesting comes from `\mathrm{}` / `\sum _{ij}`, **not `\text{}`**, which this standard never uses — a fix written against `\text{}` would have repaired nothing real. Fix: unwrap braced wrappers **before** `\frac`, balanced-brace scanner instead of `[^{}]*`, word-class run boundaries. Division lost 4 → 0; six regression cases unchanged. **Still open, as a decision not a defect:** after character-spacing a multiplication `x` is indistinguishable from the letter, so `Production = MOHH x Qty x PA x UA x Pty` renders as one fused token and costs the term filter five mentions. Calibration §12 |
|
| 173 |
+
rac` rewrite is only half-collapsed → `(I NPRHour s)`; (B) the subscript fold splits the run → `Qt y_a ctivity`; **(C) a `rac` with ` ext{}` in the numerator loses its division entirely** → `PA = Total Hours - Breakdown Total Hours × 100 %`, which states something the document does not, and it hits the PA and UA formulas. Repro: `scratchpad/repro_render.py`, calibration §12 |
|
| 174 |
| **X22** | Gold/scorer notation decision | Rifqi | ⬜ | Whether to normalise `_` in the scorer or rewrite the gold's `statement_contains`. Either edits the frozen measuring instrument, so it needs a deliberate call rather than a silent fix |
|
| 175 |
| **X13** | Conflict detection | 🔎 | ✅ | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** — one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
|
| 176 |
| **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
|
|
@@ -40,9 +40,24 @@ _SYMBOLS = {
|
|
| 40 |
# characters; letters and digits are joined separately so "C 5" is left alone.
|
| 41 |
# Subscripts are folded first (below), so a real variable like `C_p` is already
|
| 42 |
# one token and never gets swallowed into a neighbouring word.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
_SPACED_RUN = re.compile(
|
| 44 |
-
r"(?<!
|
| 45 |
-
r"|(?<!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
# `~` is a non-breaking SPACE in LaTeX, i.e. a real word boundary. It has to
|
|
@@ -51,6 +66,62 @@ _SPACED_RUN = re.compile(
|
|
| 51 |
_WORD_GAP = "\x00"
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def render_latex(latex: str) -> str:
|
| 55 |
"""LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
|
| 56 |
s = latex.replace("$$", " ").replace("$", " ")
|
|
@@ -61,30 +132,41 @@ def render_latex(latex: str) -> str:
|
|
| 61 |
s = re.sub(r"\{\s*(?:[rlc|]\s*){1,8}\}", " ", s) # column spec, e.g. { r l }
|
| 62 |
s = s.replace("\\\\", " ; ").replace("&", " ")
|
| 63 |
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
s = re.sub(r"\s*_\s*([A-Za-z0-9])", r"_\1", s)
|
| 68 |
s = re.sub(r"\s*\^\s*([A-Za-z0-9])", r"^\1", s)
|
| 69 |
|
| 70 |
-
|
| 71 |
-
for _ in range(4):
|
| 72 |
-
baru = re.sub(r"\\d?frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}", r"(\1) / (\2)", s)
|
| 73 |
-
if baru == s:
|
| 74 |
-
break
|
| 75 |
-
s = baru
|
| 76 |
|
| 77 |
s = re.sub(r"\\sqrt\s*\{([^{}]*)\}", r"sqrt(\1)", s)
|
| 78 |
for k, v in _SYMBOLS.items():
|
| 79 |
s = s.replace(k, f" {v} ")
|
| 80 |
-
s = _WRAPPERS.sub(" ", s)
|
| 81 |
s = _DROP_COMMANDS.sub(" ", s)
|
| 82 |
s = re.sub(r"\\[A-Za-z]+", " ", s) # any command left over
|
| 83 |
s = s.replace("{", " ").replace("}", " ").replace("\\", " ")
|
| 84 |
s = re.sub(r"[ \t\r\n]+", " ", s).strip()
|
| 85 |
|
| 86 |
# now that spacing is uniform, rejoin the spelled-out words and numbers
|
| 87 |
-
s =
|
| 88 |
|
| 89 |
s = s.replace(_WORD_GAP, " ")
|
| 90 |
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
| 40 |
# characters; letters and digits are joined separately so "C 5" is left alone.
|
| 41 |
# Subscripts are folded first (below), so a real variable like `C_p` is already
|
| 42 |
# one token and never gets swallowed into a neighbouring word.
|
| 43 |
+
#
|
| 44 |
+
# The boundaries are word-character classes, not `\S`. Two reasons, both found
|
| 45 |
+
# on real equations:
|
| 46 |
+
# - `\frac` rewriting INTRODUCES parentheses, and `(?<!\S)` refuses to match a
|
| 47 |
+
# run touching one, stranding a letter at each end:
|
| 48 |
+
# "(I N P R H o u r s)" -> "(I NPRHour s)".
|
| 49 |
+
# - the trailing side lets `_`/`^` follow, so a run whose last letter carries a
|
| 50 |
+
# subscript is not cut short: "Q t y_activity" -> "Qty_activity".
|
| 51 |
_SPACED_RUN = re.compile(
|
| 52 |
+
r"(?<![A-Za-z0-9_])(?:[A-Za-z]\s+){1,}[A-Za-z](?![A-Za-z0-9])"
|
| 53 |
+
r"|(?<![A-Za-z0-9_])(?:\d\s+){1,}\d(?![A-Za-z0-9])"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Wrappers that take a braced argument. Handled by brace matching rather than
|
| 57 |
+
# `_WRAPPERS` below, because merely deleting the command name leaves its braces
|
| 58 |
+
# behind — and those braces are what stopped `\frac` from matching.
|
| 59 |
+
_BRACED_WRAPPER = re.compile(
|
| 60 |
+
r"\\(?:mathrm|mathbf|mathit|mathsf|mathtt|text|textrm|textbf|operatorname)\s*(?=\{)"
|
| 61 |
)
|
| 62 |
|
| 63 |
# `~` is a non-breaking SPACE in LaTeX, i.e. a real word boundary. It has to
|
|
|
|
| 66 |
_WORD_GAP = "\x00"
|
| 67 |
|
| 68 |
|
| 69 |
+
def _rapatkan(teks: str) -> str:
|
| 70 |
+
"""Gabungkan deret karakter yang dieja satu-satu: 'i j' -> 'ij'."""
|
| 71 |
+
return _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), teks)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _argumen(s: str, i: int) -> tuple[str | None, int]:
|
| 75 |
+
"""Isi kurung SEIMBANG yang dimulai di `s[i] == '{'`.
|
| 76 |
+
|
| 77 |
+
Regex `\\{([^{}]*)\\}` tidak bisa dipakai di sini: argumen nyata mengandung
|
| 78 |
+
kurung bersarang (`\\mathrm{...}`, `\\sum _{ij}`), dan pola itu diam-diam
|
| 79 |
+
gagal cocok, bukan memberi galat.
|
| 80 |
+
"""
|
| 81 |
+
dalam = 0
|
| 82 |
+
for j in range(i, len(s)):
|
| 83 |
+
if s[j] == "{":
|
| 84 |
+
dalam += 1
|
| 85 |
+
elif s[j] == "}":
|
| 86 |
+
dalam -= 1
|
| 87 |
+
if dalam == 0:
|
| 88 |
+
return s[i + 1:j], j + 1
|
| 89 |
+
return None, i
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _buka_pembungkus(s: str) -> str:
|
| 93 |
+
r"""`\mathrm{X}` -> `X`, IKUT memakan kurungnya."""
|
| 94 |
+
for _ in range(8):
|
| 95 |
+
m = _BRACED_WRAPPER.search(s)
|
| 96 |
+
if not m:
|
| 97 |
+
break
|
| 98 |
+
isi, akhir = _argumen(s, m.end())
|
| 99 |
+
if isi is None:
|
| 100 |
+
break
|
| 101 |
+
s = s[:m.start()] + " " + isi + " " + s[akhir:]
|
| 102 |
+
return s
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _render_frac(s: str) -> str:
|
| 106 |
+
r"""`\frac{a}{b}` -> `(a) / (b)`, sadar kurung bersarang, rekursif."""
|
| 107 |
+
for _ in range(8):
|
| 108 |
+
m = re.search(r"\\d?frac\s*(?=\{)", s)
|
| 109 |
+
if not m:
|
| 110 |
+
break
|
| 111 |
+
pembilang, lanjut = _argumen(s, s.index("{", m.end() - 1))
|
| 112 |
+
if pembilang is None:
|
| 113 |
+
break
|
| 114 |
+
mulai_penyebut = s.find("{", lanjut)
|
| 115 |
+
if mulai_penyebut == -1:
|
| 116 |
+
break
|
| 117 |
+
penyebut, akhir = _argumen(s, mulai_penyebut)
|
| 118 |
+
if penyebut is None:
|
| 119 |
+
break
|
| 120 |
+
s = (s[:m.start()] + " (" + _render_frac(pembilang) + ") / ("
|
| 121 |
+
+ _render_frac(penyebut) + ") " + s[akhir:])
|
| 122 |
+
return s
|
| 123 |
+
|
| 124 |
+
|
| 125 |
def render_latex(latex: str) -> str:
|
| 126 |
"""LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
|
| 127 |
s = latex.replace("$$", " ").replace("$", " ")
|
|
|
|
| 132 |
s = re.sub(r"\{\s*(?:[rlc|]\s*){1,8}\}", " ", s) # column spec, e.g. { r l }
|
| 133 |
s = s.replace("\\\\", " ; ").replace("&", " ")
|
| 134 |
|
| 135 |
+
# ORDER MATTERS, and this is the ordering decision: unwrap the braced
|
| 136 |
+
# wrappers BEFORE touching `\frac`.
|
| 137 |
+
#
|
| 138 |
+
# `\frac`'s arguments in real documents are not brace-free — the BUMA
|
| 139 |
+
# standard writes `\frac { \mathrm { T o t a l } ~ H o u r s - ... } { ... }`.
|
| 140 |
+
# With the wrappers still in place the old `\{([^{}]*)\}` pattern could not
|
| 141 |
+
# match, so the `\frac` was never rewritten; it then fell through to the
|
| 142 |
+
# generic "drop any leftover command" pass and the DIVISION WAS SILENTLY
|
| 143 |
+
# DELETED. `PA = (Total - Breakdown) / Total` came out as
|
| 144 |
+
# `PA = Total Hours - BreakdownTotal Hours`, which reads as a product and
|
| 145 |
+
# states something the document does not. It hit 4 of 11 equations —
|
| 146 |
+
# PA, PA_ij, UA_ij, PTY_ij.
|
| 147 |
+
s = _buka_pembungkus(s)
|
| 148 |
+
|
| 149 |
+
# fold sub/superscripts, so `C _ { p }` becomes one token `C_p`. The content
|
| 150 |
+
# is joined too: `_ { i j }` -> `_ij`, otherwise the space inside survives
|
| 151 |
+
# and splits the token later ("_i j").
|
| 152 |
+
s = re.sub(r"\s*_\s*\{\s*([^{}]*?)\s*\}", lambda m: "_" + _rapatkan(m.group(1)), s)
|
| 153 |
+
s = re.sub(r"\s*\^\s*\{\s*([^{}]*?)\s*\}", lambda m: "^" + _rapatkan(m.group(1)), s)
|
| 154 |
s = re.sub(r"\s*_\s*([A-Za-z0-9])", r"_\1", s)
|
| 155 |
s = re.sub(r"\s*\^\s*([A-Za-z0-9])", r"^\1", s)
|
| 156 |
|
| 157 |
+
s = _render_frac(s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
s = re.sub(r"\\sqrt\s*\{([^{}]*)\}", r"sqrt(\1)", s)
|
| 160 |
for k, v in _SYMBOLS.items():
|
| 161 |
s = s.replace(k, f" {v} ")
|
| 162 |
+
s = _WRAPPERS.sub(" ", s) # sisa pembungkus tanpa kurung, mis. `\mathrm x`
|
| 163 |
s = _DROP_COMMANDS.sub(" ", s)
|
| 164 |
s = re.sub(r"\\[A-Za-z]+", " ", s) # any command left over
|
| 165 |
s = s.replace("{", " ").replace("}", " ").replace("\\", " ")
|
| 166 |
s = re.sub(r"[ \t\r\n]+", " ", s).strip()
|
| 167 |
|
| 168 |
# now that spacing is uniform, rejoin the spelled-out words and numbers
|
| 169 |
+
s = _rapatkan(s)
|
| 170 |
|
| 171 |
s = s.replace(_WORD_GAP, " ")
|
| 172 |
return re.sub(r"\s+", " ", s).strip()
|