Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -12,6 +12,7 @@ Primary goals for reconcile rate:
|
|
| 12 |
3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
|
| 13 |
4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
|
| 14 |
5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -498,54 +499,99 @@ def _merge_blank_header_text_columns(grid):
|
|
| 498 |
|
| 499 |
|
| 500 |
# --------------------------
|
| 501 |
-
#
|
| 502 |
# --------------------------
|
| 503 |
|
| 504 |
-
# Matches a standalone money value
|
| 505 |
-
#
|
| 506 |
-
# Covers: $3,447.10 -1,234.56 $-500.00 1000 etc.
|
| 507 |
_MONEY_RE = re.compile(
|
| 508 |
r"^[\$Β£β¬Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
|
| 509 |
)
|
| 510 |
|
| 511 |
-
# All column-header keywords
|
| 512 |
-
# Each entry is a raw regex fragment (no anchors) used to detect the keyword
|
| 513 |
-
# at the START of a header cell.
|
| 514 |
_HEADER_KW_PATTERNS = [
|
|
|
|
| 515 |
r"DATE",
|
| 516 |
r"POSTING\s+DATE",
|
| 517 |
r"VALUE\s+DATE",
|
| 518 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
r"DESCRIPTION",
|
| 520 |
r"DETAILS?",
|
| 521 |
-
r"PARTICULARS?",
|
| 522 |
r"NARRATION",
|
| 523 |
r"REMARKS?",
|
| 524 |
-
r"
|
| 525 |
-
|
| 526 |
-
|
| 527 |
r"DEBIT",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
r"CREDIT",
|
| 529 |
-
r"
|
| 530 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
r"BALANCE",
|
|
|
|
| 532 |
r"RUNNING\s+BALANCE",
|
|
|
|
| 533 |
r"AVAILABLE\s+BALANCE",
|
|
|
|
|
|
|
| 534 |
r"LEDGER\s+BALANCE",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
]
|
| 536 |
|
| 537 |
-
# Pre-
|
| 538 |
_HEADER_KW_RES = [
|
| 539 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
| 540 |
for p in _HEADER_KW_PATTERNS
|
| 541 |
]
|
| 542 |
|
| 543 |
-
#
|
| 544 |
_HEADER_KW_EXACT_RE = re.compile(
|
| 545 |
r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
|
| 546 |
re.IGNORECASE,
|
| 547 |
)
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
def _split_keyword_remainder(cell_text: str):
|
| 551 |
"""
|
|
@@ -567,20 +613,13 @@ def _extract_fused_header_artifacts(header_row):
|
|
| 567 |
Generic detector for the OCR artifact where the first data row of a table
|
| 568 |
gets fused into the header cells during OCR.
|
| 569 |
|
| 570 |
-
The artifact pattern:
|
| 571 |
-
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
money amount β "money-fused" column
|
| 578 |
-
e.g. "BALANCE$3,447.10"
|
| 579 |
-
"AMOUNT 5,000.00"
|
| 580 |
-
|
| 581 |
-
Both signals must fire simultaneously (two-signal guard). If only one
|
| 582 |
-
fires the header is left completely unchanged and None is returned β
|
| 583 |
-
this prevents false positives on clean PDFs from any bank.
|
| 584 |
|
| 585 |
Returns:
|
| 586 |
(cleaned_header : list[str], recovered_row : list[str] | None)
|
|
@@ -592,8 +631,8 @@ def _extract_fused_header_artifacts(header_row):
|
|
| 592 |
cleaned = list(header_row)
|
| 593 |
recovered = [""] * ncols
|
| 594 |
|
| 595 |
-
text_fused = []
|
| 596 |
-
money_fused = []
|
| 597 |
|
| 598 |
for idx, cell in enumerate(header_row):
|
| 599 |
cell_s = str(cell or "").strip()
|
|
@@ -603,42 +642,30 @@ def _extract_fused_header_artifacts(header_row):
|
|
| 603 |
keyword, remainder = _split_keyword_remainder(cell_s)
|
| 604 |
|
| 605 |
if not remainder:
|
| 606 |
-
# Nothing fused into this cell β leave it alone.
|
| 607 |
continue
|
| 608 |
|
| 609 |
-
# Determine what kind of remainder this is.
|
| 610 |
remainder_no_space = remainder.replace(" ", "")
|
| 611 |
|
| 612 |
if _MONEY_RE.match(remainder_no_space):
|
| 613 |
-
# Money amount fused into this header cell.
|
| 614 |
cleaned[idx] = keyword
|
| 615 |
recovered[idx] = remainder
|
| 616 |
money_fused.append(idx)
|
| 617 |
|
| 618 |
elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
|
| 619 |
-
# Free descriptive text fused into this header cell.
|
| 620 |
-
# (We exclude the case where the remainder is itself a keyword,
|
| 621 |
-
# which would just be a two-word column name like "POSTING DATE"
|
| 622 |
-
# partially matched β those are handled by the longer patterns.)
|
| 623 |
cleaned[idx] = keyword
|
| 624 |
recovered[idx] = remainder
|
| 625 |
text_fused.append(idx)
|
| 626 |
|
| 627 |
-
# Two-signal guard: only inject when BOTH kinds of fusion are present.
|
| 628 |
if text_fused and money_fused:
|
| 629 |
return cleaned, recovered
|
| 630 |
|
| 631 |
-
# One or zero signals β do not touch anything.
|
| 632 |
return list(header_row), None
|
| 633 |
|
| 634 |
|
| 635 |
def _clean_header_artifacts(grid):
|
| 636 |
"""
|
| 637 |
-
Entry point called from normalize_html_tables.
|
| 638 |
-
Applies _extract_fused_header_artifacts to the header row of the grid.
|
| 639 |
-
|
| 640 |
Returns (grid, recovered_row | None).
|
| 641 |
-
The caller inserts recovered_row at position [1] when it is not None.
|
| 642 |
"""
|
| 643 |
if not grid or not grid[0]:
|
| 644 |
return grid, None
|
|
@@ -648,14 +675,168 @@ def _clean_header_artifacts(grid):
|
|
| 648 |
return grid, recovered_row
|
| 649 |
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
def normalize_html_tables(text: str) -> str:
|
| 652 |
"""
|
| 653 |
For every <table>...</table>:
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
|
|
|
|
|
|
| 659 |
"""
|
| 660 |
if not text or "<table" not in text.lower():
|
| 661 |
return text
|
|
@@ -674,15 +855,14 @@ def normalize_html_tables(text: str) -> str:
|
|
| 674 |
grid = _drop_truly_empty_columns(grid)
|
| 675 |
grid = _merge_blank_header_text_columns(grid)
|
| 676 |
|
| 677 |
-
#
|
| 678 |
-
# recovered_row is only non-None when the two-signal guard passes
|
| 679 |
-
# (both a text-fused cell and a money-fused cell were detected).
|
| 680 |
-
# On all clean PDFs this returns None β no side effects.
|
| 681 |
grid, recovered_row = _clean_header_artifacts(grid)
|
| 682 |
-
|
| 683 |
if recovered_row is not None:
|
| 684 |
grid.insert(1, recovered_row)
|
| 685 |
|
|
|
|
|
|
|
|
|
|
| 686 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 687 |
except Exception:
|
| 688 |
out.append(table_html)
|
|
|
|
| 12 |
3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
|
| 13 |
4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
|
| 14 |
5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
|
| 15 |
+
6) promote misplaced header row: when real column headers land in a data row, restructure the grid
|
| 16 |
"""
|
| 17 |
|
| 18 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 499 |
|
| 500 |
|
| 501 |
# --------------------------
|
| 502 |
+
# Shared keyword definitions
|
| 503 |
# --------------------------
|
| 504 |
|
| 505 |
+
# Matches a standalone money value across currencies.
|
| 506 |
+
# Covers: $3,447.10 -1,234.56 Β£500.00 1000 etc.
|
|
|
|
| 507 |
_MONEY_RE = re.compile(
|
| 508 |
r"^[\$Β£β¬Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
|
| 509 |
)
|
| 510 |
|
| 511 |
+
# All column-header keywords recognised across any bank statement format.
|
|
|
|
|
|
|
| 512 |
_HEADER_KW_PATTERNS = [
|
| 513 |
+
# Date variants
|
| 514 |
r"DATE",
|
| 515 |
r"POSTING\s+DATE",
|
| 516 |
r"VALUE\s+DATE",
|
| 517 |
+
r"TXN\s+DATE",
|
| 518 |
+
r"TRANSACTION\s+DATE",
|
| 519 |
+
r"ENTRY\s+DATE",
|
| 520 |
+
r"EFFECTIVE\s+DATE",
|
| 521 |
+
|
| 522 |
+
# Transaction ID / reference
|
| 523 |
+
r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
|
| 524 |
+
r"TXN(?:\s+(?:ID|NO|TYPE))?",
|
| 525 |
+
r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
|
| 526 |
+
r"CHEQUE(?:\s*(?:NO|NUMBER))?",
|
| 527 |
+
r"CHQ(?:\s*(?:NO|NUMBER))?",
|
| 528 |
+
r"VOUCHER(?:\s*(?:NO|NUMBER))?",
|
| 529 |
+
r"SR\.?\s*NO\.?",
|
| 530 |
+
r"SERIAL(?:\s*(?:NO|NUMBER))?",
|
| 531 |
+
|
| 532 |
+
# Description variants
|
| 533 |
r"DESCRIPTION",
|
| 534 |
r"DETAILS?",
|
| 535 |
+
r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?",
|
| 536 |
r"NARRATION",
|
| 537 |
r"REMARKS?",
|
| 538 |
+
r"NOTES?",
|
| 539 |
+
|
| 540 |
+
# Debit variants
|
| 541 |
r"DEBIT",
|
| 542 |
+
r"DEBITS?",
|
| 543 |
+
r"DR\.?",
|
| 544 |
+
r"WITHDRAWALS?",
|
| 545 |
+
r"PAID\s+OUT",
|
| 546 |
+
r"MONEY\s+OUT",
|
| 547 |
+
|
| 548 |
+
# Credit variants
|
| 549 |
r"CREDIT",
|
| 550 |
+
r"CREDITS?",
|
| 551 |
+
r"CR\.?",
|
| 552 |
+
r"DEPOSITS?",
|
| 553 |
+
r"PAID\s+IN",
|
| 554 |
+
r"MONEY\s+IN",
|
| 555 |
+
|
| 556 |
+
# Amount
|
| 557 |
+
r"AMOUNT",
|
| 558 |
+
|
| 559 |
+
# Balance variants
|
| 560 |
r"BALANCE",
|
| 561 |
+
r"BAL\.?",
|
| 562 |
r"RUNNING\s+BALANCE",
|
| 563 |
+
r"RUNNING\s+BAL\.?",
|
| 564 |
r"AVAILABLE\s+BALANCE",
|
| 565 |
+
r"AVAILABLE\s+BAL\.?",
|
| 566 |
+
r"AVAIL\.?\s+BAL\.?",
|
| 567 |
r"LEDGER\s+BALANCE",
|
| 568 |
+
r"LEDGER\s+BAL\.?",
|
| 569 |
+
r"CLOSING\s+BALANCE",
|
| 570 |
+
r"CLOSING\s+BAL\.?",
|
| 571 |
+
r"OPENING\s+BALANCE",
|
| 572 |
+
r"OPENING\s+BAL\.?",
|
| 573 |
]
|
| 574 |
|
| 575 |
+
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 576 |
_HEADER_KW_RES = [
|
| 577 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
| 578 |
for p in _HEADER_KW_PATTERNS
|
| 579 |
]
|
| 580 |
|
| 581 |
+
# Quick exact-match check: "is this entire string a known keyword?"
|
| 582 |
_HEADER_KW_EXACT_RE = re.compile(
|
| 583 |
r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
|
| 584 |
re.IGNORECASE,
|
| 585 |
)
|
| 586 |
|
| 587 |
+
# Minimum fraction of cells in a row that must be pure keywords for that row
|
| 588 |
+
# to be considered a misplaced header row.
|
| 589 |
+
_HEADER_ROW_KEYWORD_THRESHOLD = 0.5
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
# --------------------------
|
| 593 |
+
# Fix 1: Fused-header recovery
|
| 594 |
+
# --------------------------
|
| 595 |
|
| 596 |
def _split_keyword_remainder(cell_text: str):
|
| 597 |
"""
|
|
|
|
| 613 |
Generic detector for the OCR artifact where the first data row of a table
|
| 614 |
gets fused into the header cells during OCR.
|
| 615 |
|
| 616 |
+
The artifact pattern (BOTH signals must fire simultaneously):
|
| 617 |
+
- Signal 1 β text-fused: a header cell contains KEYWORD + free descriptive text
|
| 618 |
+
e.g. "DESCRIPTIONBeginning Balance" "NARRATIONOpening Balance"
|
| 619 |
+
- Signal 2 β money-fused: a DIFFERENT header cell contains KEYWORD + money amount
|
| 620 |
+
e.g. "BALANCE$3,447.10" "AMOUNT 5,000.00"
|
| 621 |
+
|
| 622 |
+
Two-signal guard prevents false positives on clean PDFs from any bank.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
|
| 624 |
Returns:
|
| 625 |
(cleaned_header : list[str], recovered_row : list[str] | None)
|
|
|
|
| 631 |
cleaned = list(header_row)
|
| 632 |
recovered = [""] * ncols
|
| 633 |
|
| 634 |
+
text_fused = []
|
| 635 |
+
money_fused = []
|
| 636 |
|
| 637 |
for idx, cell in enumerate(header_row):
|
| 638 |
cell_s = str(cell or "").strip()
|
|
|
|
| 642 |
keyword, remainder = _split_keyword_remainder(cell_s)
|
| 643 |
|
| 644 |
if not remainder:
|
|
|
|
| 645 |
continue
|
| 646 |
|
|
|
|
| 647 |
remainder_no_space = remainder.replace(" ", "")
|
| 648 |
|
| 649 |
if _MONEY_RE.match(remainder_no_space):
|
|
|
|
| 650 |
cleaned[idx] = keyword
|
| 651 |
recovered[idx] = remainder
|
| 652 |
money_fused.append(idx)
|
| 653 |
|
| 654 |
elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
cleaned[idx] = keyword
|
| 656 |
recovered[idx] = remainder
|
| 657 |
text_fused.append(idx)
|
| 658 |
|
|
|
|
| 659 |
if text_fused and money_fused:
|
| 660 |
return cleaned, recovered
|
| 661 |
|
|
|
|
| 662 |
return list(header_row), None
|
| 663 |
|
| 664 |
|
| 665 |
def _clean_header_artifacts(grid):
|
| 666 |
"""
|
| 667 |
+
Entry point for Fix 1 called from normalize_html_tables.
|
|
|
|
|
|
|
| 668 |
Returns (grid, recovered_row | None).
|
|
|
|
| 669 |
"""
|
| 670 |
if not grid or not grid[0]:
|
| 671 |
return grid, None
|
|
|
|
| 675 |
return grid, recovered_row
|
| 676 |
|
| 677 |
|
| 678 |
+
# --------------------------
|
| 679 |
+
# Fix 2: Misplaced header row promotion
|
| 680 |
+
# --------------------------
|
| 681 |
+
|
| 682 |
+
def _row_keyword_score(row):
|
| 683 |
+
"""
|
| 684 |
+
Return the fraction of non-empty cells in this row that are pure header
|
| 685 |
+
keywords (e.g. DATE, DESCRIPTION, DEBIT, CREDIT, BALANCE).
|
| 686 |
+
Also handles cells like "DATE DESCRIPTION" where two keywords are space-joined
|
| 687 |
+
into one cell β these count as a keyword cell too.
|
| 688 |
+
"""
|
| 689 |
+
non_empty = [str(c or "").strip() for c in row if str(c or "").strip()]
|
| 690 |
+
if not non_empty:
|
| 691 |
+
return 0.0
|
| 692 |
+
|
| 693 |
+
keyword_hits = 0
|
| 694 |
+
for cell in non_empty:
|
| 695 |
+
# Check if the whole cell is a known keyword
|
| 696 |
+
if _HEADER_KW_EXACT_RE.match(cell):
|
| 697 |
+
keyword_hits += 1
|
| 698 |
+
continue
|
| 699 |
+
# Check if the cell is two or more space-separated keywords
|
| 700 |
+
# e.g. "DATE DESCRIPTION"
|
| 701 |
+
parts = cell.split()
|
| 702 |
+
if all(_HEADER_KW_EXACT_RE.match(p) for p in parts) and len(parts) > 1:
|
| 703 |
+
keyword_hits += 1
|
| 704 |
+
continue
|
| 705 |
+
|
| 706 |
+
return keyword_hits / len(non_empty)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def _split_merged_keyword_cell(cell_text: str, target_ncols: int, col_idx: int, total_cols: int):
|
| 710 |
+
"""
|
| 711 |
+
When a data cell contains multiple space-separated keywords (e.g. "DATE DESCRIPTION"),
|
| 712 |
+
split it into individual keyword tokens. Returns a list of strings to replace
|
| 713 |
+
the single cell β padded/trimmed to fit the available column space.
|
| 714 |
+
"""
|
| 715 |
+
parts = cell_text.strip().split()
|
| 716 |
+
# Only split if every part is a keyword
|
| 717 |
+
if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts):
|
| 718 |
+
return parts
|
| 719 |
+
return [cell_text]
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def _promote_misplaced_header_row(grid):
|
| 723 |
+
"""
|
| 724 |
+
Detects and fixes the OCR artifact where the real column headers (DATE,
|
| 725 |
+
DESCRIPTION, DEBIT, CREDIT, BALANCE β¦) land in a <td> data row instead
|
| 726 |
+
of the <th> header row.
|
| 727 |
+
|
| 728 |
+
This happens when a bank PDF wraps the transaction section in a larger
|
| 729 |
+
table that also contains section titles (e.g. "ACCOUNT ACTIVITY" and
|
| 730 |
+
"Transactions by Date (continued)"), so the OCR emits:
|
| 731 |
+
|
| 732 |
+
<th>ACCOUNT ACTIVITY</th> β wrong header (section title)
|
| 733 |
+
<td>Transactions by Date β¦</td> β subtitle row
|
| 734 |
+
<td>DATE DESCRIPTION</td> <td>DEBIT</td> <td>CREDIT</td> <td>BALANCE</td>
|
| 735 |
+
<td>01/05 β¦</td> β¦ β real data
|
| 736 |
+
|
| 737 |
+
The fix:
|
| 738 |
+
1. Scan every data row (rows 1+) for a row whose cells are predominantly
|
| 739 |
+
pure header keywords.
|
| 740 |
+
2. When found, promote that row to be the new header (row 0) and discard
|
| 741 |
+
all rows above it (they are section titles / subtitles).
|
| 742 |
+
3. If any cell in the promoted row contains multiple space-joined keywords
|
| 743 |
+
(e.g. "DATE DESCRIPTION"), expand it into separate columns and
|
| 744 |
+
re-align the data rows below accordingly.
|
| 745 |
+
|
| 746 |
+
Guard conditions (no-op if not met β safe for all other PDFs):
|
| 747 |
+
- The current header row must NOT already look like real column headers
|
| 748 |
+
(keyword score < threshold).
|
| 749 |
+
- At least one data row must have keyword score >= threshold.
|
| 750 |
+
- The candidate row must appear within the first 5 data rows (it is
|
| 751 |
+
always near the top of the table, never buried mid-table).
|
| 752 |
+
"""
|
| 753 |
+
if not grid or len(grid) < 2:
|
| 754 |
+
return grid
|
| 755 |
+
|
| 756 |
+
current_header = grid[0]
|
| 757 |
+
current_header_score = _row_keyword_score(current_header)
|
| 758 |
+
|
| 759 |
+
# If the current header already looks like real column headers, do nothing.
|
| 760 |
+
if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD:
|
| 761 |
+
return grid
|
| 762 |
+
|
| 763 |
+
# Search the first few data rows for a candidate header row.
|
| 764 |
+
candidate_idx = None
|
| 765 |
+
candidate_score = 0.0
|
| 766 |
+
search_limit = min(len(grid), 6) # rows 1..5
|
| 767 |
+
|
| 768 |
+
for i in range(1, search_limit):
|
| 769 |
+
score = _row_keyword_score(grid[i])
|
| 770 |
+
if score >= _HEADER_ROW_KEYWORD_THRESHOLD and score > candidate_score:
|
| 771 |
+
candidate_score = score
|
| 772 |
+
candidate_idx = i
|
| 773 |
+
|
| 774 |
+
if candidate_idx is None:
|
| 775 |
+
return grid
|
| 776 |
+
|
| 777 |
+
# --- Promote the candidate row ---
|
| 778 |
+
candidate_row = grid[candidate_idx]
|
| 779 |
+
|
| 780 |
+
# Expand any merged keyword cells (e.g. "DATE DESCRIPTION" β ["DATE", "DESCRIPTION"])
|
| 781 |
+
expanded_header = []
|
| 782 |
+
for cell in candidate_row:
|
| 783 |
+
cell_s = str(cell or "").strip()
|
| 784 |
+
parts = cell_s.split()
|
| 785 |
+
if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts):
|
| 786 |
+
expanded_header.extend(parts)
|
| 787 |
+
else:
|
| 788 |
+
expanded_header.append(cell_s)
|
| 789 |
+
|
| 790 |
+
new_ncols = len(expanded_header)
|
| 791 |
+
old_ncols = len(candidate_row)
|
| 792 |
+
col_expansion = new_ncols - old_ncols # how many extra columns were added
|
| 793 |
+
|
| 794 |
+
# Re-align data rows below the candidate (expand first cell if needed)
|
| 795 |
+
new_data_rows = []
|
| 796 |
+
for row in grid[candidate_idx + 1:]:
|
| 797 |
+
if col_expansion > 0 and row:
|
| 798 |
+
# The first cell of a data row often contains DATE + start of DESCRIPTION
|
| 799 |
+
# fused together (e.g. "01/05 TD ZELLE RECEIVED,...").
|
| 800 |
+
# Split it into [date_part, description_part] to fill the expanded columns.
|
| 801 |
+
first_cell = str(row[0] or "").strip()
|
| 802 |
+
date_match = re.match(r"^(\d{1,2}/\d{1,2})\s+(.*)", first_cell, re.DOTALL)
|
| 803 |
+
if date_match and col_expansion == 1:
|
| 804 |
+
new_row = [date_match.group(1), date_match.group(2).strip()] + list(row[1:])
|
| 805 |
+
else:
|
| 806 |
+
# Can't split cleanly β pad with empty cells to maintain column count
|
| 807 |
+
new_row = list(row) + [""] * col_expansion
|
| 808 |
+
new_data_rows.append(new_row)
|
| 809 |
+
else:
|
| 810 |
+
new_data_rows.append(list(row))
|
| 811 |
+
|
| 812 |
+
# Normalise all rows to new_ncols width
|
| 813 |
+
def pad(row, n):
|
| 814 |
+
r = list(row)
|
| 815 |
+
while len(r) < n:
|
| 816 |
+
r.append("")
|
| 817 |
+
return r[:n]
|
| 818 |
+
|
| 819 |
+
new_grid = [pad(expanded_header, new_ncols)]
|
| 820 |
+
for row in new_data_rows:
|
| 821 |
+
new_grid.append(pad(row, new_ncols))
|
| 822 |
+
|
| 823 |
+
return new_grid
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
# --------------------------
|
| 827 |
+
# Normalizer entry point
|
| 828 |
+
# --------------------------
|
| 829 |
+
|
| 830 |
def normalize_html_tables(text: str) -> str:
|
| 831 |
"""
|
| 832 |
For every <table>...</table>:
|
| 833 |
+
1. Parse to grid (expand colspan/rowspan).
|
| 834 |
+
2. Drop truly-empty columns.
|
| 835 |
+
3. Merge blank-header text columns into the left column.
|
| 836 |
+
4. Fix 1 β clean fused header artifacts and recover the lost first data row.
|
| 837 |
+
5. Fix 2 β promote a misplaced header row when real column headers landed
|
| 838 |
+
in a <td> data row instead of the <th> header row.
|
| 839 |
+
6. Emit normalised <table> HTML.
|
| 840 |
"""
|
| 841 |
if not text or "<table" not in text.lower():
|
| 842 |
return text
|
|
|
|
| 855 |
grid = _drop_truly_empty_columns(grid)
|
| 856 |
grid = _merge_blank_header_text_columns(grid)
|
| 857 |
|
| 858 |
+
# Fix 1: fused-header artifact (two-signal guard β safe on clean PDFs)
|
|
|
|
|
|
|
|
|
|
| 859 |
grid, recovered_row = _clean_header_artifacts(grid)
|
|
|
|
| 860 |
if recovered_row is not None:
|
| 861 |
grid.insert(1, recovered_row)
|
| 862 |
|
| 863 |
+
# Fix 2: misplaced header row promotion (keyword-score guard β safe on clean PDFs)
|
| 864 |
+
grid = _promote_misplaced_header_row(grid)
|
| 865 |
+
|
| 866 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 867 |
except Exception:
|
| 868 |
out.append(table_html)
|