Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -83,7 +83,6 @@ MIN_CROP_PIXELS = 112 * 112
|
|
| 83 |
|
| 84 |
_parser = None
|
| 85 |
|
| 86 |
-
|
| 87 |
def get_parser():
|
| 88 |
global _parser
|
| 89 |
if _parser is None:
|
|
@@ -91,7 +90,6 @@ def get_parser():
|
|
| 91 |
_parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
|
| 92 |
return _parser
|
| 93 |
|
| 94 |
-
|
| 95 |
# ---------------------------------------------------------------------------
|
| 96 |
# Best-effort config tweaks (safe to fail on read-only HF env)
|
| 97 |
# ---------------------------------------------------------------------------
|
|
@@ -123,7 +121,6 @@ try:
|
|
| 123 |
except Exception:
|
| 124 |
pass
|
| 125 |
|
| 126 |
-
|
| 127 |
# --------------------------
|
| 128 |
# Header/footer helpers
|
| 129 |
# --------------------------
|
|
@@ -140,7 +137,6 @@ def get_header_footer_zones(regions, norm_height=1000):
|
|
| 140 |
return None, None
|
| 141 |
return min(y_tops) / norm_height, max(y_bottoms) / norm_height
|
| 142 |
|
| 143 |
-
|
| 144 |
def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 145 |
try:
|
| 146 |
import pymupdf as fitz
|
|
@@ -154,7 +150,6 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
| 154 |
except Exception:
|
| 155 |
return ""
|
| 156 |
|
| 157 |
-
|
| 158 |
def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 159 |
try:
|
| 160 |
import pymupdf as fitz
|
|
@@ -175,7 +170,6 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
| 175 |
except Exception:
|
| 176 |
return ""
|
| 177 |
|
| 178 |
-
|
| 179 |
def ocr_zone(image_path, y_start_frac, y_end_frac):
|
| 180 |
zone_name = "header" if y_end_frac < 0.5 else "footer"
|
| 181 |
try:
|
|
@@ -221,7 +215,6 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
|
|
| 221 |
log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True)
|
| 222 |
return ""
|
| 223 |
|
| 224 |
-
|
| 225 |
def fix_account_number(hdr: str) -> str:
|
| 226 |
if not hdr:
|
| 227 |
return hdr
|
|
@@ -236,7 +229,6 @@ def fix_account_number(hdr: str) -> str:
|
|
| 236 |
hdr = hdr[len(acct):].lstrip()
|
| 237 |
return hdr
|
| 238 |
|
| 239 |
-
|
| 240 |
# --------------------------
|
| 241 |
# Table stabilization helpers
|
| 242 |
# --------------------------
|
|
@@ -256,7 +248,6 @@ def close_unclosed_html(md: str) -> str:
|
|
| 256 |
md += ("</%s>" % tag) * (opened - closed)
|
| 257 |
return md
|
| 258 |
|
| 259 |
-
|
| 260 |
def looks_like_markdown_table(block: str) -> bool:
|
| 261 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 262 |
if len(lines) < 2:
|
|
@@ -266,7 +257,6 @@ def looks_like_markdown_table(block: str) -> bool:
|
|
| 266 |
sep = lines[1].replace(" ", "")
|
| 267 |
return ("---" in sep) and ("|" in sep)
|
| 268 |
|
| 269 |
-
|
| 270 |
def md_table_to_html(block: str) -> str:
|
| 271 |
lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
|
| 272 |
if len(lines) < 2:
|
|
@@ -292,7 +282,6 @@ def md_table_to_html(block: str) -> str:
|
|
| 292 |
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>")
|
| 293 |
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 294 |
|
| 295 |
-
|
| 296 |
def normalize_money_glyphs(text: str) -> str:
|
| 297 |
if not text:
|
| 298 |
return text
|
|
@@ -310,7 +299,6 @@ def normalize_money_glyphs(text: str) -> str:
|
|
| 310 |
t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
|
| 311 |
return t
|
| 312 |
|
| 313 |
-
|
| 314 |
# ---- Generic HTML table normalizer ----
|
| 315 |
class TableGridParser(HTMLParser):
|
| 316 |
"""Parse a <table> into rows of (text, colspan, rowspan)."""
|
|
@@ -346,7 +334,6 @@ class TableGridParser(HTMLParser):
|
|
| 346 |
if self._in_cell:
|
| 347 |
self._cell_text.append(data)
|
| 348 |
|
| 349 |
-
|
| 350 |
def _build_grid(rows_data):
|
| 351 |
if not rows_data:
|
| 352 |
return []
|
|
@@ -372,7 +359,6 @@ def _build_grid(rows_data):
|
|
| 372 |
row.append("")
|
| 373 |
return grid
|
| 374 |
|
| 375 |
-
|
| 376 |
def _grid_to_html(grid):
|
| 377 |
if not grid:
|
| 378 |
return ""
|
|
@@ -393,13 +379,11 @@ def _grid_to_html(grid):
|
|
| 393 |
lines.append("</table>")
|
| 394 |
return "\n".join(lines)
|
| 395 |
|
| 396 |
-
|
| 397 |
def _is_amount_like(s: str) -> bool:
|
| 398 |
if not s:
|
| 399 |
return False
|
| 400 |
return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
|
| 401 |
|
| 402 |
-
|
| 403 |
def _drop_truly_empty_columns(grid):
|
| 404 |
"""
|
| 405 |
Drop columns that are empty in header AND almost always empty in body.
|
|
@@ -438,7 +422,6 @@ def _drop_truly_empty_columns(grid):
|
|
| 438 |
new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
|
| 439 |
return new_grid
|
| 440 |
|
| 441 |
-
|
| 442 |
def _merge_blank_header_text_columns(grid):
|
| 443 |
"""
|
| 444 |
If the header row has blank columns, and most body rows have non-empty *text* in that
|
|
@@ -502,7 +485,6 @@ def _merge_blank_header_text_columns(grid):
|
|
| 502 |
new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
|
| 503 |
return new_grid
|
| 504 |
|
| 505 |
-
|
| 506 |
# --------------------------
|
| 507 |
# Shared keyword definitions
|
| 508 |
# --------------------------
|
|
@@ -588,7 +570,6 @@ _HEADER_KW_EXACT_RE = re.compile(
|
|
| 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 |
# --------------------------
|
|
@@ -607,7 +588,6 @@ def _split_keyword_remainder(cell_text: str):
|
|
| 607 |
return keyword, remainder
|
| 608 |
return s, ""
|
| 609 |
|
| 610 |
-
|
| 611 |
def _extract_fused_header_artifacts(header_row):
|
| 612 |
"""
|
| 613 |
Generic detector for the OCR artifact where the first data row of a table
|
|
@@ -661,7 +641,6 @@ def _extract_fused_header_artifacts(header_row):
|
|
| 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.
|
|
@@ -674,7 +653,6 @@ def _clean_header_artifacts(grid):
|
|
| 674 |
grid[0] = cleaned_header
|
| 675 |
return grid, recovered_row
|
| 676 |
|
| 677 |
-
|
| 678 |
# --------------------------
|
| 679 |
# Fix 2: Misplaced header row promotion
|
| 680 |
# --------------------------
|
|
@@ -702,7 +680,6 @@ def _row_keyword_score(row):
|
|
| 702 |
|
| 703 |
return keyword_hits / len(non_empty)
|
| 704 |
|
| 705 |
-
|
| 706 |
# Pattern: header cell contains balance/account info β do NOT promote away from it.
|
| 707 |
# Matches things like "Beginning Balance:", "Ending Balance:", account numbers, "$20.48$3.46"
|
| 708 |
_BALANCE_INFO_RE = re.compile(
|
|
@@ -713,7 +690,6 @@ _BALANCE_INFO_RE = re.compile(
|
|
| 713 |
re.IGNORECASE,
|
| 714 |
)
|
| 715 |
|
| 716 |
-
|
| 717 |
def _header_contains_balance_info(header_row):
|
| 718 |
"""
|
| 719 |
Return True if any cell in the header row contains balance or account
|
|
@@ -725,7 +701,6 @@ def _header_contains_balance_info(header_row):
|
|
| 725 |
return True
|
| 726 |
return False
|
| 727 |
|
| 728 |
-
|
| 729 |
def _promote_misplaced_header_row(grid):
|
| 730 |
"""
|
| 731 |
Detects and fixes the OCR artifact where real column headers land in a
|
|
@@ -826,7 +801,6 @@ def _promote_misplaced_header_row(grid):
|
|
| 826 |
|
| 827 |
return new_grid
|
| 828 |
|
| 829 |
-
|
| 830 |
# --------------------------
|
| 831 |
# Fix 3: Fused key-value rows in summary sections
|
| 832 |
# --------------------------
|
|
@@ -840,7 +814,6 @@ _FUSED_KV_RE = re.compile(
|
|
| 840 |
re.DOTALL,
|
| 841 |
)
|
| 842 |
|
| 843 |
-
|
| 844 |
def _fix_fused_keyvalue_rows(grid):
|
| 845 |
"""
|
| 846 |
Fix rows where label+value are fused into the first cell with all other
|
|
@@ -930,7 +903,6 @@ def _fix_fused_keyvalue_rows(grid):
|
|
| 930 |
|
| 931 |
return new_grid
|
| 932 |
|
| 933 |
-
|
| 934 |
# --------------------------
|
| 935 |
# Fix 4: PDF text-layer row-count patch
|
| 936 |
# --------------------------
|
|
@@ -1074,6 +1046,40 @@ def _extract_textlayer_rows(pdf_path: str, page_num: int):
|
|
| 1074 |
return []
|
| 1075 |
|
| 1076 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
|
| 1078 |
"""
|
| 1079 |
Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
|
|
@@ -1141,6 +1147,9 @@ def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, nee
|
|
| 1141 |
break
|
| 1142 |
i += 1
|
| 1143 |
|
|
|
|
|
|
|
|
|
|
| 1144 |
if checks:
|
| 1145 |
rows = "\n".join(
|
| 1146 |
"<tr><td>" + d + "</td><td>" + n + "</td><td>" + a + "</td></tr>"
|
|
@@ -1148,7 +1157,7 @@ def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, nee
|
|
| 1148 |
)
|
| 1149 |
output.append("Checks")
|
| 1150 |
output.append(
|
| 1151 |
-
|
| 1152 |
+ rows + "\n</table>"
|
| 1153 |
)
|
| 1154 |
|
|
@@ -1429,8 +1438,6 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1429 |
log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
|
| 1430 |
return page_md # always safe β return original on any error
|
| 1431 |
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
# --------------------------
|
| 1435 |
# Fix 5: Mid-table separator row reconstruction
|
| 1436 |
# --------------------------
|
|
@@ -1443,7 +1450,6 @@ _SPLIT_AMOUNT_RE = re.compile(
|
|
| 1443 |
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 1444 |
)
|
| 1445 |
|
| 1446 |
-
|
| 1447 |
def _reconstruct_separator_rows(grid):
|
| 1448 |
"""
|
| 1449 |
Detect and reconstruct mid-table separator / label rows that OCR has
|
|
@@ -1643,7 +1649,6 @@ def _merge_split_rows(grid):
|
|
| 1643 |
|
| 1644 |
return new_grid
|
| 1645 |
|
| 1646 |
-
|
| 1647 |
def _extract_fused_desc_amount(grid):
|
| 1648 |
"""
|
| 1649 |
Fix OCR artifact where a trailing money amount gets fused into the
|
|
@@ -1707,7 +1712,6 @@ def _extract_fused_desc_amount(grid):
|
|
| 1707 |
|
| 1708 |
return new_grid
|
| 1709 |
|
| 1710 |
-
|
| 1711 |
def _is_junk_table(grid):
|
| 1712 |
"""
|
| 1713 |
Detect and suppress known junk tables that are OCR artifacts from
|
|
@@ -1732,7 +1736,6 @@ def _is_junk_table(grid):
|
|
| 1732 |
return True
|
| 1733 |
return False
|
| 1734 |
|
| 1735 |
-
|
| 1736 |
def _split_fused_multicolumn_header(grid):
|
| 1737 |
"""
|
| 1738 |
Fix OCR artifact where multiple column headers are fused into one <th> cell.
|
|
@@ -1851,7 +1854,6 @@ def _split_fused_multicolumn_header(grid):
|
|
| 1851 |
|
| 1852 |
return new_grid
|
| 1853 |
|
| 1854 |
-
|
| 1855 |
def _normalize_daily_balance_table(grid):
|
| 1856 |
"""
|
| 1857 |
Fix OCR artifact in DAILY BALANCE SUMMARY tables where:
|
|
@@ -1929,7 +1931,6 @@ def _normalize_daily_balance_table(grid):
|
|
| 1929 |
|
| 1930 |
return [new_header] + new_rows
|
| 1931 |
|
| 1932 |
-
|
| 1933 |
def _normalize_checks_paid_table(grid):
|
| 1934 |
"""
|
| 1935 |
Fix OCR artifact in CHECKS PAID SUMMARY tables where:
|
|
@@ -2007,7 +2008,6 @@ def _normalize_checks_paid_table(grid):
|
|
| 2007 |
|
| 2008 |
return new_rows
|
| 2009 |
|
| 2010 |
-
|
| 2011 |
# ββ Johnson Bank plain-text section converter βββββββββββββββββββββββββββββ
|
| 2012 |
# Johnson Bank formats Deposits, Withdrawals, Checks, and Daily Account Balance
|
| 2013 |
# as plain text columns (no HTML tables). This converter detects those sections
|
|
@@ -2035,7 +2035,6 @@ _JB_DATE_DD = re.compile(r"^\d{2}-\d{2}\s+")
|
|
| 2035 |
_JB_TXN_ROW = re.compile(r"^(\d{2}-\d{2})\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
|
| 2036 |
_JB_BAL_ROW = re.compile(r"^(\d{2}-\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
|
| 2037 |
|
| 2038 |
-
|
| 2039 |
def _jb_rows_to_html(header_cols, rows):
|
| 2040 |
th = "".join("<th>" + h + "</th>" for h in header_cols)
|
| 2041 |
parts = ["<table>", "<tr>" + th + "</tr>"]
|
|
@@ -2045,12 +2044,10 @@ def _jb_rows_to_html(header_cols, rows):
|
|
| 2045 |
parts.append("</table>")
|
| 2046 |
return "\n".join(parts)
|
| 2047 |
|
| 2048 |
-
|
| 2049 |
def _jb_section_has_table(lines, start, end):
|
| 2050 |
"""Return True if any line in lines[start:end] contains a <table tag."""
|
| 2051 |
return any("<table" in l.lower() for l in lines[start:end])
|
| 2052 |
|
| 2053 |
-
|
| 2054 |
def _find_first_data_row(lines, start):
|
| 2055 |
"""Return index of first non-blank line after start, or None."""
|
| 2056 |
for i in range(start, min(start + 20, len(lines))):
|
|
@@ -2058,7 +2055,6 @@ def _find_first_data_row(lines, start):
|
|
| 2058 |
return i
|
| 2059 |
return None
|
| 2060 |
|
| 2061 |
-
|
| 2062 |
def convert_plaintext_bank_sections(text: str) -> str:
|
| 2063 |
"""
|
| 2064 |
Convert Johnson Bank plain-text transaction sections to HTML tables.
|
|
@@ -2192,7 +2188,6 @@ def convert_plaintext_bank_sections(text: str) -> str:
|
|
| 2192 |
|
| 2193 |
return "\n".join(out)
|
| 2194 |
|
| 2195 |
-
|
| 2196 |
def normalize_html_tables(text: str) -> str:
|
| 2197 |
"""
|
| 2198 |
For every <table>...</table>:
|
|
@@ -2269,7 +2264,6 @@ def normalize_html_tables(text: str) -> str:
|
|
| 2269 |
out.append(text[last:])
|
| 2270 |
return "".join(out)
|
| 2271 |
|
| 2272 |
-
|
| 2273 |
def stabilize_tables_and_text(page_md: str) -> str:
|
| 2274 |
if not page_md:
|
| 2275 |
return page_md
|
|
@@ -2289,7 +2283,6 @@ def stabilize_tables_and_text(page_md: str) -> str:
|
|
| 2289 |
stabilized = normalize_html_tables(stabilized)
|
| 2290 |
return close_unclosed_html(stabilized)
|
| 2291 |
|
| 2292 |
-
|
| 2293 |
# --------------------------
|
| 2294 |
# PDF rendering with padding
|
| 2295 |
# --------------------------
|
|
@@ -2329,7 +2322,6 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
|
| 2329 |
doc.close()
|
| 2330 |
return page_images, page_heights
|
| 2331 |
|
| 2332 |
-
|
| 2333 |
# --------------------------
|
| 2334 |
# GLM-OCR result extraction
|
| 2335 |
# --------------------------
|
|
@@ -2350,7 +2342,6 @@ def get_page_md_and_regions(page_result):
|
|
| 2350 |
regions = r.get("regions") or []
|
| 2351 |
return md, regions
|
| 2352 |
|
| 2353 |
-
|
| 2354 |
# --------------------------
|
| 2355 |
# Main entry
|
| 2356 |
# --------------------------
|
|
@@ -2414,7 +2405,7 @@ def run_ocr(uploaded_file):
|
|
| 2414 |
# Run OUTSIDE _patch_ocr_with_textlayer so exceptions there don't block it.
|
| 2415 |
if is_pdf:
|
| 2416 |
try:
|
| 2417 |
-
needs_checks = "checks" not in stabilized
|
| 2418 |
needs_dab = "daily account balance" not in stabilized.lower()
|
| 2419 |
if needs_checks or needs_dab:
|
| 2420 |
# Try pdfminer first (column-aware), fall back to pymupdf
|
|
@@ -2495,7 +2486,6 @@ def run_ocr(uploaded_file):
|
|
| 2495 |
except Exception:
|
| 2496 |
pass
|
| 2497 |
|
| 2498 |
-
|
| 2499 |
with gr.Blocks(title="GLM-OCR") as demo:
|
| 2500 |
gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
|
| 2501 |
file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
|
|
|
|
| 83 |
|
| 84 |
_parser = None
|
| 85 |
|
|
|
|
| 86 |
def get_parser():
|
| 87 |
global _parser
|
| 88 |
if _parser is None:
|
|
|
|
| 90 |
_parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
|
| 91 |
return _parser
|
| 92 |
|
|
|
|
| 93 |
# ---------------------------------------------------------------------------
|
| 94 |
# Best-effort config tweaks (safe to fail on read-only HF env)
|
| 95 |
# ---------------------------------------------------------------------------
|
|
|
|
| 121 |
except Exception:
|
| 122 |
pass
|
| 123 |
|
|
|
|
| 124 |
# --------------------------
|
| 125 |
# Header/footer helpers
|
| 126 |
# --------------------------
|
|
|
|
| 137 |
return None, None
|
| 138 |
return min(y_tops) / norm_height, max(y_bottoms) / norm_height
|
| 139 |
|
|
|
|
| 140 |
def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 141 |
try:
|
| 142 |
import pymupdf as fitz
|
|
|
|
| 150 |
except Exception:
|
| 151 |
return ""
|
| 152 |
|
|
|
|
| 153 |
def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 154 |
try:
|
| 155 |
import pymupdf as fitz
|
|
|
|
| 170 |
except Exception:
|
| 171 |
return ""
|
| 172 |
|
|
|
|
| 173 |
def ocr_zone(image_path, y_start_frac, y_end_frac):
|
| 174 |
zone_name = "header" if y_end_frac < 0.5 else "footer"
|
| 175 |
try:
|
|
|
|
| 215 |
log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True)
|
| 216 |
return ""
|
| 217 |
|
|
|
|
| 218 |
def fix_account_number(hdr: str) -> str:
|
| 219 |
if not hdr:
|
| 220 |
return hdr
|
|
|
|
| 229 |
hdr = hdr[len(acct):].lstrip()
|
| 230 |
return hdr
|
| 231 |
|
|
|
|
| 232 |
# --------------------------
|
| 233 |
# Table stabilization helpers
|
| 234 |
# --------------------------
|
|
|
|
| 248 |
md += ("</%s>" % tag) * (opened - closed)
|
| 249 |
return md
|
| 250 |
|
|
|
|
| 251 |
def looks_like_markdown_table(block: str) -> bool:
|
| 252 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 253 |
if len(lines) < 2:
|
|
|
|
| 257 |
sep = lines[1].replace(" ", "")
|
| 258 |
return ("---" in sep) and ("|" in sep)
|
| 259 |
|
|
|
|
| 260 |
def md_table_to_html(block: str) -> str:
|
| 261 |
lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
|
| 262 |
if len(lines) < 2:
|
|
|
|
| 282 |
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>")
|
| 283 |
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 284 |
|
|
|
|
| 285 |
def normalize_money_glyphs(text: str) -> str:
|
| 286 |
if not text:
|
| 287 |
return text
|
|
|
|
| 299 |
t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
|
| 300 |
return t
|
| 301 |
|
|
|
|
| 302 |
# ---- Generic HTML table normalizer ----
|
| 303 |
class TableGridParser(HTMLParser):
|
| 304 |
"""Parse a <table> into rows of (text, colspan, rowspan)."""
|
|
|
|
| 334 |
if self._in_cell:
|
| 335 |
self._cell_text.append(data)
|
| 336 |
|
|
|
|
| 337 |
def _build_grid(rows_data):
|
| 338 |
if not rows_data:
|
| 339 |
return []
|
|
|
|
| 359 |
row.append("")
|
| 360 |
return grid
|
| 361 |
|
|
|
|
| 362 |
def _grid_to_html(grid):
|
| 363 |
if not grid:
|
| 364 |
return ""
|
|
|
|
| 379 |
lines.append("</table>")
|
| 380 |
return "\n".join(lines)
|
| 381 |
|
|
|
|
| 382 |
def _is_amount_like(s: str) -> bool:
|
| 383 |
if not s:
|
| 384 |
return False
|
| 385 |
return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
|
| 386 |
|
|
|
|
| 387 |
def _drop_truly_empty_columns(grid):
|
| 388 |
"""
|
| 389 |
Drop columns that are empty in header AND almost always empty in body.
|
|
|
|
| 422 |
new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
|
| 423 |
return new_grid
|
| 424 |
|
|
|
|
| 425 |
def _merge_blank_header_text_columns(grid):
|
| 426 |
"""
|
| 427 |
If the header row has blank columns, and most body rows have non-empty *text* in that
|
|
|
|
| 485 |
new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
|
| 486 |
return new_grid
|
| 487 |
|
|
|
|
| 488 |
# --------------------------
|
| 489 |
# Shared keyword definitions
|
| 490 |
# --------------------------
|
|
|
|
| 570 |
# to be considered a misplaced header row.
|
| 571 |
_HEADER_ROW_KEYWORD_THRESHOLD = 0.5
|
| 572 |
|
|
|
|
| 573 |
# --------------------------
|
| 574 |
# Fix 1: Fused-header recovery
|
| 575 |
# --------------------------
|
|
|
|
| 588 |
return keyword, remainder
|
| 589 |
return s, ""
|
| 590 |
|
|
|
|
| 591 |
def _extract_fused_header_artifacts(header_row):
|
| 592 |
"""
|
| 593 |
Generic detector for the OCR artifact where the first data row of a table
|
|
|
|
| 641 |
|
| 642 |
return list(header_row), None
|
| 643 |
|
|
|
|
| 644 |
def _clean_header_artifacts(grid):
|
| 645 |
"""
|
| 646 |
Entry point for Fix 1 called from normalize_html_tables.
|
|
|
|
| 653 |
grid[0] = cleaned_header
|
| 654 |
return grid, recovered_row
|
| 655 |
|
|
|
|
| 656 |
# --------------------------
|
| 657 |
# Fix 2: Misplaced header row promotion
|
| 658 |
# --------------------------
|
|
|
|
| 680 |
|
| 681 |
return keyword_hits / len(non_empty)
|
| 682 |
|
|
|
|
| 683 |
# Pattern: header cell contains balance/account info β do NOT promote away from it.
|
| 684 |
# Matches things like "Beginning Balance:", "Ending Balance:", account numbers, "$20.48$3.46"
|
| 685 |
_BALANCE_INFO_RE = re.compile(
|
|
|
|
| 690 |
re.IGNORECASE,
|
| 691 |
)
|
| 692 |
|
|
|
|
| 693 |
def _header_contains_balance_info(header_row):
|
| 694 |
"""
|
| 695 |
Return True if any cell in the header row contains balance or account
|
|
|
|
| 701 |
return True
|
| 702 |
return False
|
| 703 |
|
|
|
|
| 704 |
def _promote_misplaced_header_row(grid):
|
| 705 |
"""
|
| 706 |
Detects and fixes the OCR artifact where real column headers land in a
|
|
|
|
| 801 |
|
| 802 |
return new_grid
|
| 803 |
|
|
|
|
| 804 |
# --------------------------
|
| 805 |
# Fix 3: Fused key-value rows in summary sections
|
| 806 |
# --------------------------
|
|
|
|
| 814 |
re.DOTALL,
|
| 815 |
)
|
| 816 |
|
|
|
|
| 817 |
def _fix_fused_keyvalue_rows(grid):
|
| 818 |
"""
|
| 819 |
Fix rows where label+value are fused into the first cell with all other
|
|
|
|
| 903 |
|
| 904 |
return new_grid
|
| 905 |
|
|
|
|
| 906 |
# --------------------------
|
| 907 |
# Fix 4: PDF text-layer row-count patch
|
| 908 |
# --------------------------
|
|
|
|
| 1046 |
return []
|
| 1047 |
|
| 1048 |
|
| 1049 |
+
def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]:
|
| 1050 |
+
"""
|
| 1051 |
+
Johnson Bank: when pdfminer column extraction fails (e.g. pymupdf line text or
|
| 1052 |
+
multi-column layout), extract (date, check#, amount) triplets from the Checks
|
| 1053 |
+
subsection. Anchored by 'Checks (' ... 'Daily Account Balance' only.
|
| 1054 |
+
"""
|
| 1055 |
+
if not text_layer:
|
| 1056 |
+
return []
|
| 1057 |
+
m = re.search(
|
| 1058 |
+
r"Checks\s*\([^\n]*\n(.*?)(?=^Daily\s+Account\s+Balance\s*$)",
|
| 1059 |
+
text_layer,
|
| 1060 |
+
re.DOTALL | re.MULTILINE | re.IGNORECASE,
|
| 1061 |
+
)
|
| 1062 |
+
if not m:
|
| 1063 |
+
return []
|
| 1064 |
+
chunk = m.group(1)
|
| 1065 |
+
chunk = re.sub(
|
| 1066 |
+
r"^\s*Date\s+Number\s+Amount\s*$",
|
| 1067 |
+
"",
|
| 1068 |
+
chunk,
|
| 1069 |
+
flags=re.MULTILINE | re.IGNORECASE,
|
| 1070 |
+
)
|
| 1071 |
+
_triplet = re.compile(
|
| 1072 |
+
r"(\d{2}-\d{2})\s+(\*?\d+)\s+(\d{1,3}(?:,\d{3})*\.\d{2})"
|
| 1073 |
+
)
|
| 1074 |
+
out: List[Tuple[str, str, str]] = []
|
| 1075 |
+
seen = set()
|
| 1076 |
+
for t in _triplet.findall(chunk):
|
| 1077 |
+
if t not in seen:
|
| 1078 |
+
seen.add(t)
|
| 1079 |
+
out.append(t)
|
| 1080 |
+
return out
|
| 1081 |
+
|
| 1082 |
+
|
| 1083 |
def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
|
| 1084 |
"""
|
| 1085 |
Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
|
|
|
|
| 1147 |
break
|
| 1148 |
i += 1
|
| 1149 |
|
| 1150 |
+
if not checks and re.search(r"\bChecks\s*\(", text_layer, re.I):
|
| 1151 |
+
checks = _jb_extract_checks_fallback(text_layer)
|
| 1152 |
+
|
| 1153 |
if checks:
|
| 1154 |
rows = "\n".join(
|
| 1155 |
"<tr><td>" + d + "</td><td>" + n + "</td><td>" + a + "</td></tr>"
|
|
|
|
| 1157 |
)
|
| 1158 |
output.append("Checks")
|
| 1159 |
output.append(
|
| 1160 |
+
'<table class="jb-summary-checks">\n<tr><th>Date</th><th>Number</th><th>Amount</th></tr>\n'
|
| 1161 |
+ rows + "\n</table>"
|
| 1162 |
)
|
| 1163 |
|
|
|
|
| 1438 |
log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
|
| 1439 |
return page_md # always safe β return original on any error
|
| 1440 |
|
|
|
|
|
|
|
| 1441 |
# --------------------------
|
| 1442 |
# Fix 5: Mid-table separator row reconstruction
|
| 1443 |
# --------------------------
|
|
|
|
| 1450 |
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 1451 |
)
|
| 1452 |
|
|
|
|
| 1453 |
def _reconstruct_separator_rows(grid):
|
| 1454 |
"""
|
| 1455 |
Detect and reconstruct mid-table separator / label rows that OCR has
|
|
|
|
| 1649 |
|
| 1650 |
return new_grid
|
| 1651 |
|
|
|
|
| 1652 |
def _extract_fused_desc_amount(grid):
|
| 1653 |
"""
|
| 1654 |
Fix OCR artifact where a trailing money amount gets fused into the
|
|
|
|
| 1712 |
|
| 1713 |
return new_grid
|
| 1714 |
|
|
|
|
| 1715 |
def _is_junk_table(grid):
|
| 1716 |
"""
|
| 1717 |
Detect and suppress known junk tables that are OCR artifacts from
|
|
|
|
| 1736 |
return True
|
| 1737 |
return False
|
| 1738 |
|
|
|
|
| 1739 |
def _split_fused_multicolumn_header(grid):
|
| 1740 |
"""
|
| 1741 |
Fix OCR artifact where multiple column headers are fused into one <th> cell.
|
|
|
|
| 1854 |
|
| 1855 |
return new_grid
|
| 1856 |
|
|
|
|
| 1857 |
def _normalize_daily_balance_table(grid):
|
| 1858 |
"""
|
| 1859 |
Fix OCR artifact in DAILY BALANCE SUMMARY tables where:
|
|
|
|
| 1931 |
|
| 1932 |
return [new_header] + new_rows
|
| 1933 |
|
|
|
|
| 1934 |
def _normalize_checks_paid_table(grid):
|
| 1935 |
"""
|
| 1936 |
Fix OCR artifact in CHECKS PAID SUMMARY tables where:
|
|
|
|
| 2008 |
|
| 2009 |
return new_rows
|
| 2010 |
|
|
|
|
| 2011 |
# ββ Johnson Bank plain-text section converter βββββββββββββββββββββββββββββ
|
| 2012 |
# Johnson Bank formats Deposits, Withdrawals, Checks, and Daily Account Balance
|
| 2013 |
# as plain text columns (no HTML tables). This converter detects those sections
|
|
|
|
| 2035 |
_JB_TXN_ROW = re.compile(r"^(\d{2}-\d{2})\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
|
| 2036 |
_JB_BAL_ROW = re.compile(r"^(\d{2}-\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
|
| 2037 |
|
|
|
|
| 2038 |
def _jb_rows_to_html(header_cols, rows):
|
| 2039 |
th = "".join("<th>" + h + "</th>" for h in header_cols)
|
| 2040 |
parts = ["<table>", "<tr>" + th + "</tr>"]
|
|
|
|
| 2044 |
parts.append("</table>")
|
| 2045 |
return "\n".join(parts)
|
| 2046 |
|
|
|
|
| 2047 |
def _jb_section_has_table(lines, start, end):
|
| 2048 |
"""Return True if any line in lines[start:end] contains a <table tag."""
|
| 2049 |
return any("<table" in l.lower() for l in lines[start:end])
|
| 2050 |
|
|
|
|
| 2051 |
def _find_first_data_row(lines, start):
|
| 2052 |
"""Return index of first non-blank line after start, or None."""
|
| 2053 |
for i in range(start, min(start + 20, len(lines))):
|
|
|
|
| 2055 |
return i
|
| 2056 |
return None
|
| 2057 |
|
|
|
|
| 2058 |
def convert_plaintext_bank_sections(text: str) -> str:
|
| 2059 |
"""
|
| 2060 |
Convert Johnson Bank plain-text transaction sections to HTML tables.
|
|
|
|
| 2188 |
|
| 2189 |
return "\n".join(out)
|
| 2190 |
|
|
|
|
| 2191 |
def normalize_html_tables(text: str) -> str:
|
| 2192 |
"""
|
| 2193 |
For every <table>...</table>:
|
|
|
|
| 2264 |
out.append(text[last:])
|
| 2265 |
return "".join(out)
|
| 2266 |
|
|
|
|
| 2267 |
def stabilize_tables_and_text(page_md: str) -> str:
|
| 2268 |
if not page_md:
|
| 2269 |
return page_md
|
|
|
|
| 2283 |
stabilized = normalize_html_tables(stabilized)
|
| 2284 |
return close_unclosed_html(stabilized)
|
| 2285 |
|
|
|
|
| 2286 |
# --------------------------
|
| 2287 |
# PDF rendering with padding
|
| 2288 |
# --------------------------
|
|
|
|
| 2322 |
doc.close()
|
| 2323 |
return page_images, page_heights
|
| 2324 |
|
|
|
|
| 2325 |
# --------------------------
|
| 2326 |
# GLM-OCR result extraction
|
| 2327 |
# --------------------------
|
|
|
|
| 2342 |
regions = r.get("regions") or []
|
| 2343 |
return md, regions
|
| 2344 |
|
|
|
|
| 2345 |
# --------------------------
|
| 2346 |
# Main entry
|
| 2347 |
# --------------------------
|
|
|
|
| 2405 |
# Run OUTSIDE _patch_ocr_with_textlayer so exceptions there don't block it.
|
| 2406 |
if is_pdf:
|
| 2407 |
try:
|
| 2408 |
+
needs_checks = 'class="jb-summary-checks"' not in stabilized
|
| 2409 |
needs_dab = "daily account balance" not in stabilized.lower()
|
| 2410 |
if needs_checks or needs_dab:
|
| 2411 |
# Try pdfminer first (column-aware), fall back to pymupdf
|
|
|
|
| 2486 |
except Exception:
|
| 2487 |
pass
|
| 2488 |
|
|
|
|
| 2489 |
with gr.Blocks(title="GLM-OCR") as demo:
|
| 2490 |
gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
|
| 2491 |
file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
|