Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1586,6 +1586,150 @@ def _extract_fused_desc_amount(grid):
|
|
| 1586 |
return new_grid
|
| 1587 |
|
| 1588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1589 |
def normalize_html_tables(text: str) -> str:
|
| 1590 |
"""
|
| 1591 |
For every <table>...</table>:
|
|
@@ -1612,6 +1756,13 @@ def normalize_html_tables(text: str) -> str:
|
|
| 1612 |
p = TableGridParser()
|
| 1613 |
p.feed(table_html)
|
| 1614 |
grid = _build_grid(p.rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1615 |
grid = _drop_truly_empty_columns(grid)
|
| 1616 |
grid = _merge_blank_header_text_columns(grid)
|
| 1617 |
|
|
@@ -1626,6 +1777,9 @@ def normalize_html_tables(text: str) -> str:
|
|
| 1626 |
# Fix 2: misplaced header row promotion (keyword-score guard — safe on clean PDFs)
|
| 1627 |
grid = _promote_misplaced_header_row(grid)
|
| 1628 |
|
|
|
|
|
|
|
|
|
|
| 1629 |
# Fix 6: merge split rows where description wraps to next line
|
| 1630 |
# (Hardin County Bank and similar monospace statement formats)
|
| 1631 |
grid = _merge_split_rows(grid)
|
|
|
|
| 1586 |
return new_grid
|
| 1587 |
|
| 1588 |
|
| 1589 |
+
def _is_junk_table(grid):
|
| 1590 |
+
"""
|
| 1591 |
+
Detect and suppress known junk tables that are OCR artifacts from
|
| 1592 |
+
page headers/footers, not real transaction data.
|
| 1593 |
+
|
| 1594 |
+
Current patterns:
|
| 1595 |
+
1. CUSTOMER INFORMATION fragment tables (First Horizon Bank):
|
| 1596 |
+
Single-column table with header "CUSTOMER INFORMATION" and
|
| 1597 |
+
data rows that are short numeric fragments (e.g. "254", "24")
|
| 1598 |
+
from the partial account number / year printed in the header band.
|
| 1599 |
+
"""
|
| 1600 |
+
if not grid or len(grid) < 2:
|
| 1601 |
+
return False
|
| 1602 |
+
ncols = len(grid[0])
|
| 1603 |
+
if ncols != 1:
|
| 1604 |
+
return False
|
| 1605 |
+
header_text = str(grid[0][0] or "").strip().upper()
|
| 1606 |
+
if "CUSTOMER INFORMATION" in header_text:
|
| 1607 |
+
# All data rows should be short (< 10 chars) numeric/alphanumeric fragments
|
| 1608 |
+
data_rows = grid[1:]
|
| 1609 |
+
if all(len(str(r[0] or "").strip()) <= 10 for r in data_rows):
|
| 1610 |
+
return True
|
| 1611 |
+
return False
|
| 1612 |
+
|
| 1613 |
+
|
| 1614 |
+
def _split_fused_multicolumn_header(grid):
|
| 1615 |
+
"""
|
| 1616 |
+
Fix OCR artifact where multiple column headers are fused into one <th> cell.
|
| 1617 |
+
|
| 1618 |
+
Example (First Horizon Bank alternating pages):
|
| 1619 |
+
Fused: ['DATE DESCRIPTION CARD #', 'DEPOSIT', 'WITHDRAWAL']
|
| 1620 |
+
Correct: ['DATE', 'DESCRIPTION', 'DEPOSIT', 'WITHDRAWAL', 'CARD #']
|
| 1621 |
+
|
| 1622 |
+
The data rows also have date + description + card# all in col 0:
|
| 1623 |
+
Fused: ['01/16 PURCHASE - UBER TRIP... 4919', '', '$21.02']
|
| 1624 |
+
Correct: ['01/16', 'PURCHASE - UBER TRIP...', '', '$21.02', '4919']
|
| 1625 |
+
|
| 1626 |
+
Detection: first header cell contains 2+ keyword terms separated by spaces,
|
| 1627 |
+
AND remaining header cells are valid money column keywords.
|
| 1628 |
+
"""
|
| 1629 |
+
if not grid or len(grid) < 2:
|
| 1630 |
+
return grid
|
| 1631 |
+
|
| 1632 |
+
ncols = len(grid[0])
|
| 1633 |
+
if ncols < 2:
|
| 1634 |
+
return grid
|
| 1635 |
+
|
| 1636 |
+
first_header = str(grid[0][0] or "").strip()
|
| 1637 |
+
# Split first header cell into parts and check if multiple are keywords
|
| 1638 |
+
parts = first_header.split()
|
| 1639 |
+
kw_hits = [p for p in parts if _HEADER_KW_EXACT_RE.match(p)]
|
| 1640 |
+
if len(kw_hits) < 2:
|
| 1641 |
+
return grid # not a fused multi-keyword header
|
| 1642 |
+
|
| 1643 |
+
# The remaining header cells must all be keyword columns
|
| 1644 |
+
rest_headers = [str(c or "").strip() for c in grid[0][1:]]
|
| 1645 |
+
if not all(_HEADER_KW_EXACT_RE.match(h) for h in rest_headers if h):
|
| 1646 |
+
return grid
|
| 1647 |
+
|
| 1648 |
+
# Build the new expanded header:
|
| 1649 |
+
# Split the fused first cell into individual keyword columns
|
| 1650 |
+
# Keep non-keyword trailing parts (e.g. "CARD #") as the last new col
|
| 1651 |
+
new_header_cols = []
|
| 1652 |
+
non_kw_parts = []
|
| 1653 |
+
for p in parts:
|
| 1654 |
+
if _HEADER_KW_EXACT_RE.match(p):
|
| 1655 |
+
if non_kw_parts:
|
| 1656 |
+
new_header_cols.append(" ".join(non_kw_parts))
|
| 1657 |
+
non_kw_parts = []
|
| 1658 |
+
new_header_cols.append(p)
|
| 1659 |
+
else:
|
| 1660 |
+
non_kw_parts.append(p)
|
| 1661 |
+
if non_kw_parts:
|
| 1662 |
+
new_header_cols.append(" ".join(non_kw_parts))
|
| 1663 |
+
|
| 1664 |
+
# Full new header = expanded first cell + remaining cells
|
| 1665 |
+
new_header = new_header_cols + rest_headers
|
| 1666 |
+
new_ncols = len(new_header)
|
| 1667 |
+
extra_cols = new_ncols - ncols # how many new columns were added
|
| 1668 |
+
|
| 1669 |
+
# Identify column roles in the new header
|
| 1670 |
+
_DATE_COL_RE = re.compile(r"\bDATE\b", re.IGNORECASE)
|
| 1671 |
+
_CARD_COL_RE = re.compile(r"\bCARD\b", re.IGNORECASE)
|
| 1672 |
+
_DESC_COL_RE = re.compile(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?)\b", re.IGNORECASE)
|
| 1673 |
+
_MONEY_COL_RE = re.compile(r"\b(DEPOSIT|WITHDRAWAL|DEBIT|CREDIT|AMOUNT|ADDITION|SUBTRACTION)S?\b", re.IGNORECASE)
|
| 1674 |
+
|
| 1675 |
+
new_date_col = next((i for i,h in enumerate(new_header) if _DATE_COL_RE.search(h)), None)
|
| 1676 |
+
new_desc_col = next((i for i,h in enumerate(new_header) if _DESC_COL_RE.search(h)), None)
|
| 1677 |
+
new_card_col = next((i for i,h in enumerate(new_header) if _CARD_COL_RE.search(h)), None)
|
| 1678 |
+
|
| 1679 |
+
if new_date_col is None or new_desc_col is None:
|
| 1680 |
+
return grid # can't map columns
|
| 1681 |
+
|
| 1682 |
+
# Re-split each data row: col 0 had "date description... card#" fused together
|
| 1683 |
+
_DATE_PREFIX = re.compile(r"^(\d{1,2}/\d{2}(?:/\d{2,4})?)\s+(.*)", re.DOTALL)
|
| 1684 |
+
_CARD_SUFFIX = re.compile(r"^(.*?)\s+(\d{4})$", re.DOTALL) # 4-digit card number at end
|
| 1685 |
+
|
| 1686 |
+
def pad(row, n):
|
| 1687 |
+
r = list(row)
|
| 1688 |
+
while len(r) < n: r.append("")
|
| 1689 |
+
return r[:n]
|
| 1690 |
+
|
| 1691 |
+
new_grid = [pad(new_header, new_ncols)]
|
| 1692 |
+
for row in grid[1:]:
|
| 1693 |
+
cells = [str(c or "").strip() for c in row]
|
| 1694 |
+
while len(cells) < ncols: cells.append("")
|
| 1695 |
+
|
| 1696 |
+
fused_cell = cells[0]
|
| 1697 |
+
rest_cells = cells[1:]
|
| 1698 |
+
|
| 1699 |
+
# Extract date from front
|
| 1700 |
+
date_val = ""
|
| 1701 |
+
desc_val = fused_cell
|
| 1702 |
+
card_val = ""
|
| 1703 |
+
|
| 1704 |
+
dm = _DATE_PREFIX.match(fused_cell)
|
| 1705 |
+
if dm:
|
| 1706 |
+
date_val = dm.group(1)
|
| 1707 |
+
desc_val = dm.group(2).strip()
|
| 1708 |
+
|
| 1709 |
+
# Extract card# from end (4-digit number)
|
| 1710 |
+
if new_card_col is not None:
|
| 1711 |
+
cm = _CARD_SUFFIX.match(desc_val)
|
| 1712 |
+
if cm:
|
| 1713 |
+
desc_val = cm.group(1).strip()
|
| 1714 |
+
card_val = cm.group(2)
|
| 1715 |
+
|
| 1716 |
+
# Build new row
|
| 1717 |
+
new_row = [""] * new_ncols
|
| 1718 |
+
new_row[new_date_col] = date_val
|
| 1719 |
+
new_row[new_desc_col] = desc_val
|
| 1720 |
+
if new_card_col is not None:
|
| 1721 |
+
new_row[new_card_col] = card_val
|
| 1722 |
+
# Fill remaining money columns from rest_cells
|
| 1723 |
+
money_col_indices = [i for i,h in enumerate(new_header) if _MONEY_COL_RE.search(h)]
|
| 1724 |
+
for mi, ri in enumerate(range(len(rest_cells))):
|
| 1725 |
+
if mi < len(money_col_indices):
|
| 1726 |
+
new_row[money_col_indices[mi]] = rest_cells[ri]
|
| 1727 |
+
|
| 1728 |
+
new_grid.append(new_row)
|
| 1729 |
+
|
| 1730 |
+
return new_grid
|
| 1731 |
+
|
| 1732 |
+
|
| 1733 |
def normalize_html_tables(text: str) -> str:
|
| 1734 |
"""
|
| 1735 |
For every <table>...</table>:
|
|
|
|
| 1756 |
p = TableGridParser()
|
| 1757 |
p.feed(table_html)
|
| 1758 |
grid = _build_grid(p.rows)
|
| 1759 |
+
|
| 1760 |
+
# Suppress known junk tables (OCR artifacts from page headers)
|
| 1761 |
+
if _is_junk_table(grid):
|
| 1762 |
+
out.append("")
|
| 1763 |
+
last = m.end()
|
| 1764 |
+
continue
|
| 1765 |
+
|
| 1766 |
grid = _drop_truly_empty_columns(grid)
|
| 1767 |
grid = _merge_blank_header_text_columns(grid)
|
| 1768 |
|
|
|
|
| 1777 |
# Fix 2: misplaced header row promotion (keyword-score guard — safe on clean PDFs)
|
| 1778 |
grid = _promote_misplaced_header_row(grid)
|
| 1779 |
|
| 1780 |
+
# Fix 8: split fused multi-keyword header cell (First Horizon Bank)
|
| 1781 |
+
grid = _split_fused_multicolumn_header(grid)
|
| 1782 |
+
|
| 1783 |
# Fix 6: merge split rows where description wraps to next line
|
| 1784 |
# (Hardin County Bank and similar monospace statement formats)
|
| 1785 |
grid = _merge_split_rows(grid)
|