diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,273 +1,110 @@ +#!/usr/bin/env python3 """ -GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion -and table-structure stabilization for downstream bank-statement pipelines. -Hard-coded knobs (no environment variables required). -Primary goals for reconcile rate: -- preserve right-most columns (often "Balance") by higher DPI render + right padding -- keep tables as tables (convert markdown pipe tables -> HTML table) -- return ---page-separator--- between pages -- normalize HTML tables generically so downstream parsing/classification is stable: - 1) expand colspan/rowspan into a rectangular grid - 2) drop truly-empty columns (common in summary tables) - 3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split) - 4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10") - 5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire) - 6) promote misplaced header row: when real column headers land in a data row, restructure the grid - 7) fix fused key-value rows in summary sections (e.g. Interest Summary) appended to transaction tables - 8) reconstruct mid-table separator rows split across columns by OCR (e.g. 'Card account # XXXX 2 | | 889') -- footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it) -- Fix 4: cross-validate OCR table row counts against PDF text layer; inject missing duplicate rows - (safe no-op for scanned PDFs and non-transaction pages); after injection, re-run normalize_html_tables - + subset dedupe so UCB-style fused cells from the text layer get Fix 13/14 -- Fix NF-1: extract Navy Federal "Summary of your deposit accounts" table from PDF text layer - using spatial word positions (GLM-OCR fails on this wide-column layout) -- Fix 13: collapse Date|Description|Amount|Description|Amount tables to 3 columns; fix fused - Beginning/Ending balance rows (UCB-style wide summary tables) -- Fix 14: UCB 3-col tables where Beginning Balance amount is fused into description and Amount is $0.00 -- Fix 15: UCB (and similar) 3-col rows where Date+Description are fused in col1 and Amount sits in col2 with col3 empty -- Fix 16: remove duplicate rows within a single Date|Description|Amount table; multi-pass table dedupe + final doc-wide dedupe -- Fix 17: UCB page 3 — split one merged table under Deposits (continued) into Deposits + Electronic Credits + - Electronic Debits (PDF text order); drop orphan empty EC/ED headers that followed the merged table -- Fix 17b: When markdown has Electronic Credits (+ optional Electronic Debits headings) but a single fused - Date|Description|Amount table (common when Deposits (continued) is absent from OCR), split after a - leading ITM DEPOSIT run when the next row matches generic electronic-debit patterns (POS purchase, - ATM withdrawal, etc.). Safe no-op when there is no ITM prefix or no such debit row after it. -- Fix 17c: When Electronic Debits appears *before* the table but every data row is credit-side (no strong - debit pattern) and at least one ITM DEPOSIT row exists, OCR stacked headings wrong — emit - Electronic Credits, then the table, then Electronic Debits (section continues below). Safe no-op - if the Debits heading was not before the table or any row looks like a card/ATM debit. -- Fix 17c-html: Same as 17c using raw HTML cues when TableGridParser/header normalization would skip the table. -- Fix 17d: Drop a second Electronic Debits block + DA3 table when its rows are a loose (date+amount) - multiset subset of the immediately preceding DA3 table (OCR duplicate tail / wrong split). -- Fix 17f: Same as 17d when the duplicate table *starts with* ITM DEPOSIT rows (copied from Electronic Credits) - then repeats debits from the table above — 17d alone misses those because ITM keys are not in the - debits-only predecessor table. Strip ITM rows from the multiset check, then apply the 17d subset rule. -- Fix 17g: When 17f cannot drop the whole block (e.g. one orphan debit row only appears in the duplicate tail), - drop leading ITM rows and each fragment row whose (date, amount) still has count in the previous - debits table; remove the ED wrapper if the table becomes empty. If a small orphan tail remains - (≤6 rows), append those rows to the preceding debits table and drop the extra ED heading. - Skip the *first* Electronic Debits + table on the page when the previous table is ITM/credits-only - and the fragment is clearly debits (so we do not treat the main debits section as a duplicate). -- Fix 17h: UCB sometimes OCRs Electronic Credits (+ optional Electronic Debits) then the *main debits* DA3 - table first and a *second* DA3 table whose rows start with ITM DEPOSIT (credits) plus duplicate - debits. Reorder to Credits + ITM table + Electronic Debits + merged debits; drop dup keys from the - second table against the first; merge remaining orphan rows into the debits table. -- Fix 17i: After Fix 17b splits ITM prefix from debits, OCR often leaves *credit* rows (POS Return, DDA - Transfer IN, Apple Cash INST R / CUPERTINO) in the debits grid — move those rows to Electronic - Credits (contiguous-tail case on g_ed after 17b, or any matching row via 17j). -- Fix 17j: When OCR emits a *second* Electronic Debits table (duplicate block), credit-only rows often appear - only there — relocate them to the nearest preceding Electronic Credits table (same patterns as 17i). -- Fix 17e: Repeat EC/ED fused + 17d passes until stable (handles chained replacements). Centered section titles - match both align=\"center\" and style=\"text-align:center\" on divs. -- Post-pass: dedupe 3-col Date|Description|Amount tables when one is a duplicate fragment - (later subset of earlier, earlier subset of later, or identical row sets) -- Fix 18: adjacent DA3 tables — if the last K data rows of table N match the first K rows of table N+1 - (same normalized Date|Description|Amount keys), strip that suffix from table N. Fixes - formats (e.g. Truist) where deposit rows are glued onto the withdrawals table and repeated - under the proper deposits heading (K>=2; first table must keep >=1 data row). -- Fix 19: DA3 rows whose Amount cell is not strict currency (e.g. fused card digits + merchant tail) - — take the rightmost plausible money token from Description+Amount text for the amount. -- Fix 20: DA3 in-table dedupe — cap consecutive identical normalized keys at 2 rows (keeps legitimate - back-to-back identical charges; still collapses 3+ OCR stutters). -- Fix 21: Cross-table DA3 repair for MSBILL / MICROSOFT#G… PIN rows — when Amount is junk in one table - but another table repeats the same authorization id with a strict amount, copy that amount - (then subset dedupe can drop the stray duplicate block, e.g. under Deposits). -- Fix 22: Drop a single OCR "flat line" between pages: (continued) + DESCRIPTION + AMOUNT + many MM/DD - tokens (noise before a proper ). -- Fix 23: Doc-wide DA3 table dedupe using multiset of (date, amount) keys — catches duplicate sections when - OCR varies description text between copies (e.g. withdrawals repeated under Deposits). -- Fix 24: Run orphan flatline strip on merged PDF output and after text-layer patch so page-boundary - noise is removed consistently. -- Fix 25: After a centered "deposits + credits/interest" heading, if two DA3 tables appear back-to-back - and the first is mostly credit/deposit rows while the second is mostly debit/withdrawal-style - rows (generic keywords), drop the second — fixes OCR pasting the withdrawals block again. -- Fix 23b: Loose multiset keys use normalized float amounts so comma/spacing variants still match. -- Fix 26: Drop small DA3 fragments (3–14 rows) that are a multiset subset of an earlier larger DA3 - table — matches PDF layouts that re-print a header and repeat a few rows. -- Fix 27: Strip a lone "…(continued)" account banner line (no DATE/DESCRIPTION/AMOUNT) between - page separators — duplicate header noise without the long flatline. -- Fix 28: Strip leading credit/deposit rows that OCR prepends to a withdrawals/debits DA3 table - when the preceding DA3 table was under a deposits/credits heading. Only strips the - contiguous prefix of the debit table whose rows appear in the credit table; requires - >=1 data row to remain; entire function wrapped in try/except (safe no-op on any error). -- Fix 29: Move a misplaced debit/withdrawal heading to before its orphan DA3 table when OCR - places the heading AFTER the table instead of before it. Pattern detected: - [credit heading+table] ... [orphan DA3 table] ... [debit heading] — the heading is - relocated to just before the orphan table. Guards: no debit heading already before - table; credit context must precede table; no other table between orphan and heading; - try/except wrapper (safe no-op on any error). -- Fix 31: On check / receipt image pages, GLM sometimes emits bogus table rows (e.g. \"Witness LA…\", - \"[hotline Control]\" after amounts, long trace ids). Strip those rows and bracket junk after - currency amounts; drop a stray \"## UCB\" heading before such tables. Safe no-op when absent. -- Fix 20-override: _dedupe_duplicate_rows_in_da3_table now uses the PDF text-layer frequency - map (_tl_freq_context) as its cap so legitimate repeated transactions (e.g. 6x - Temporary Credit Reversal on the same date/amount) are preserved rather than - being collapsed to 2. Keys absent from the text layer still cap at 2 (OCR stutter - behaviour unchanged). Safe no-op for scanned PDFs with no text layer. -- Fix 30: Drop small DA3 fragments (1-5 rows) that are a loose (date+amount) subset of an - earlier larger DA3 table — catches OCR-truncated description duplicates such as - the BoA service-fee page where a VIDDYOZE -1.11 fragment table appears after the - full service-fee table with a longer description string. -- Fix 32: Headless trailing " - for d, n, a in checks - ) - output.append("Checks") - output.append( - '
over currency cells — reuse the prior money header (Additions/ - Subtractions) when present, else \"Amount\". Fix 32b aligns Date|Transaction Description| - Additions/Subtractions tables to the PDF: credits as Number|Date|Description|Additions - (blank Number when unused); debits as Date|Description|Subtractions (no Number); merges - OCR column splits. Adjacent duplicate HTML tables (same header + row multiset) are removed. -- Fix TD-1: TD Bank continuation register: (a) four OCR columns with DATE+DESCRIPTION fused + - DEBIT/CREDIT/BALANCE — split col0 into Date|Description; (b) three OCR columns - Date|Transaction Description|Amount where Amount is the running balance — expand to - five columns using trailing amount in the description cell and TD-specific debit/credit - routing. Fires on \"continued\" in-table text and/or TD body cues (TD ZELLE, LENDINGCLUB, - etc.). Safe no-op elsewhere. -- Fix SE-CONT: CO-OP / credit-union account banner (BUSINESS - N [CONT]) often emits three columns - | Amount | Amount | (date+desc | amount | balance) or a four-column variant (date+desc - fragment | desc tail | amount | balance). CONT may be omitted by OCR. Downstream mis-maps - amount vs balance. Normalize to Date | Transaction Description | Transaction Amount | Balance. -- Fix DA4-dedupe: In 4-column registers whose amount header is SEFCU-style (contains both \"transaction\" - and \"amount\"), consecutive rows identical on all four cells are OCR stutters — collapse to one; - honour text-layer counts when present. Skips tables with plain \"Amount\" / \"Balance($)\" headers. -- Fix SE-CONT banner guard: reject cells containing product words (checking, savings, inspire, advantage, …) - so Amegy / BofA marketing rows and Navy \"Business Checking-…\" continuations are never rewritten. -- Fix MD-1: Markdown fidelity — (a) collapse adjacent HTML tables that are the same register - (identical multiset of date + debit + credit + balance per row, descriptions may differ); - keep the table with richer description text so output matches the PDF once. (b) Common OCR - typos (e.g. CINTHIAL→CINTHIA L) and double-escaped ampersands (&→&). - Gap between tables must not contain another or page-separator. -- Fix MD-2: Skip blank-header column merge for YOUR CHECKS SEQUENCED–style tables (title + repeated - Amount columns + blank th between triplets); merge was collapsing multi-column check rows. - Also skip when the table is ACCOUNT HISTORY / DEPOSIT+WITHDRAWAL (First Horizon–style): blank - " + "".join(f"" for c in cols[: len(header)]) + "") + html_rows.append( + "" + "".join(f"" for c in cols[: len(header)]) + "" + ) return "
align subheaders on the next row; merge was fusing DEPOSIT/WITHDRAWAL/CARD into wrong columns. -- Fix MD-3: Check image pages — GLM often emits ![](bbox…) plus
fragments - (e.g. 2293 $6,721.00 4/29/2025). Collapse detected gallery pages into one Check #|Amount|Date - table when bbox/image markers + repeated check lines are present; skips chunks with YOUR CHECKS SEQUENCED. -- Fix MD-4: YOUR CHECKS SEQUENCED wide grid (N×DATE|CHECK#|AMOUNT per row) is unfolded to one DATE|CHECK#|AMOUNT - table so narrow viewers show every check, not only the first date column. -- Fix FH-1: Drop a leading Date|Description|Amount (DA3) table when its (date, amount) multiset is a - strict subset of a later DEPOSIT/WITHDRAWAL register (e.g. First Horizon OCR duplicate before - ACCOUNT SUMMARY). Safe no-op when no such pair exists. -- Fix FH-2: Normalize packed DAILY BALANCE SUMMARY and CHECKS PAID SUMMARY tables into one-row-per-item - layouts; also promote multiline register headers where row0 is placeholder and row1 has true - DATE/DESCRIPTION/DEPOSIT/WITHDRAWAL labels. -- Fix SE-multi: Multi-account deposit summary tables (Beginning/Deposits/Withdrawals/Ending) sometimes get a - bogus OCR sub-row with tiny amounts (e.g. -$1 / $2 / $1) next to a real account row. Drop those - dust rows when another row has statement-scale balances, then recompute the TOTAL row from the - remaining account rows. Generic; no institution names. +Simplified GLM-OCR Hugging Face / local Gradio app. + +Scope (intentionally small): + - PDF → padded high-DPI page images → GLM-OCR body markdown + - Header band: PDF text extraction first, optional header OCR fallback + - Footer band: same pattern, with light dedup so we do not paste a full + transaction dump twice when the body already captured it + +Explicitly omitted vs the heavy Space build: + - No HTML table rewriting, text-layer row injection, institution-specific + splits (UCB / Navy / TD / First Horizon / …), or doc-wide dedupe passes + +Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio + +pymupdf + pillow installed. """ -# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise +# Patch asyncio first (before Gradio imports it) to reduce Python 3.13 loop noise import asyncio + try: _orig_close = asyncio.BaseEventLoop.close + def _safe_close(self): try: _orig_close(self) except (ValueError, OSError): pass + asyncio.BaseEventLoop.close = _safe_close except Exception: pass +import html import logging import os import re -import html import tempfile from typing import List, Optional, Tuple -from collections import Counter, defaultdict -from html.parser import HTMLParser import yaml -# Gradio and glmocr are optional at import: pro_processor only needs stabilize_tables_and_text -# (glm_ocr_client). HF Space / run_ocr still use glmocr + gradio when installed. try: import glmocr GLMOCR_BASE = os.path.dirname(glmocr.__file__) CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml") - FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py") except ImportError: glmocr = None # type: ignore GLMOCR_BASE = "" CONFIG_PATH = "" - FORMATTER_PATH = "" -log = logging.getLogger("glmocr_app") +log = logging.getLogger("glmocr_simple_app") logging.basicConfig(level=logging.INFO) -# ============================================================ -# HARD-CODED SETTINGS (edit these numbers to tune quality/speed) -# ============================================================ - -GLMOCR_API_KEY = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito" +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- -# Higher = better OCR for small/right-aligned digits; slower -RENDER_SCALE = 2.2 # try 2.5 if right-side numbers are missed +GLMOCR_API_KEY = os.environ.get("GLMOCR_API_KEY", "").strip() +if not GLMOCR_API_KEY: + log.warning("GLMOCR_API_KEY is not set; GlmOcr() will fail until you export it.") -# Padding to protect columns near edges (Balance is often right-most) +RENDER_SCALE = 2.2 PAD_LEFT_FRAC = 0.02 -PAD_RIGHT_FRAC = 0.06 # try 0.10 if right-most balances are missing +PAD_RIGHT_FRAC = 0.06 PAD_TOP_FRAC = 0.01 PAD_BOTTOM_FRAC = 0.01 - ENABLE_CONTRAST = True DEFAULT_ZONE_FRAC = 0.12 PDF_HEADER_BAND_FRAC = 0.10 -ENABLE_FOOTER_OCR = True # enabled — 3-step fallback with dedup guard +ENABLE_FOOTER_OCR = True PDF_FOOTER_BAND_FRAC = 0.88 MIN_CROP_HEIGHT = 112 MIN_CROP_PIXELS = 112 * 112 -# ============================================================ - _parser = None + def get_parser(): global _parser + if glmocr is None: + raise RuntimeError("glmocr is not installed.") if _parser is None: from glmocr import GlmOcr + _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas") return _parser -# --------------------------------------------------------------------------- -# Best-effort config tweaks (safe to fail on read-only HF env) -# --------------------------------------------------------------------------- + if CONFIG_PATH: try: - with open(CONFIG_PATH, "r") as f: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = yaml.safe_load(f) + config.setdefault("pipeline", {}).setdefault("maas", {}) config["pipeline"]["maas"]["enabled"] = True config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY - with open(CONFIG_PATH, "w") as f: + with open(CONFIG_PATH, "w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) except Exception: pass -# Best-effort formatter tweak: avoid stripping header/footer labels -if FORMATTER_PATH: - try: - with open(FORMATTER_PATH, "r") as f: - source = f.read() - for label in ( - '"header"', "'header'", - '"footer"', "'footer'", - '"doc_header"', "'doc_header'", - '"doc_footer"', "'doc_footer'", - ): - source = re.sub(r",\s*" + re.escape(label), "", source) - source = re.sub(re.escape(label) + r"\s*,", "", source) - source = re.sub(re.escape(label), "", source) - with open(FORMATTER_PATH, "w") as f: - f.write(source) - except Exception: - pass -# -------------------------- -# Header/footer helpers -# -------------------------- def get_header_footer_zones(regions, norm_height=1000): if not regions: return None, None @@ -281,9 +118,11 @@ def get_header_footer_zones(regions, norm_height=1000): return None, None return min(y_tops) / norm_height, max(y_bottoms) / norm_height + def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac): try: import pymupdf as fitz + doc = fitz.open(pdf_path) page = doc[page_num] h, w = page.rect.height, page.rect.width @@ -294,9 +133,11 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac): except Exception: return "" + def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac): try: import pymupdf as fitz + doc = fitz.open(pdf_path) page = doc[page_num] h = page.rect.height @@ -314,10 +155,12 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac): except Exception: return "" + def ocr_zone(image_path, y_start_frac, y_end_frac): zone_name = "header" if y_end_frac < 0.5 else "footer" try: from PIL import Image + img = Image.open(image_path).convert("RGB") w, h = img.size y0 = max(0, int(h * y_start_frac)) @@ -359,6 +202,7 @@ def ocr_zone(image_path, y_start_frac, y_end_frac): log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True) return "" + def fix_account_number(hdr: str) -> str: if not hdr: return hdr @@ -370,12 +214,10 @@ def fix_account_number(hdr: str) -> str: if acct_match: acct = acct_match.group(1) if hdr.startswith(acct): - hdr = hdr[len(acct):].lstrip() + hdr = hdr[len(acct) :].lstrip() return hdr -# -------------------------- -# Table stabilization helpers -# -------------------------- + def close_unclosed_html(md: str) -> str: if not md: return md @@ -392,6 +234,7 @@ def close_unclosed_html(md: str) -> str: md += ("" % tag) * (opened - closed) return md + def looks_like_markdown_table(block: str) -> bool: lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()] if len(lines) < 2: @@ -401,6 +244,7 @@ def looks_like_markdown_table(block: str) -> bool: sep = lines[1].replace(" ", "") return ("---" in sep) and ("|" in sep) + def md_table_to_html(block: str) -> str: lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()] if len(lines) < 2: @@ -423,9 +267,12 @@ def md_table_to_html(block: str) -> str: cols = split_row(ln) if len(cols) < len(header): cols += [""] * (len(header) - len(cols)) - html_rows.append("
{html.escape(c)}
{html.escape(c)}
\n" + "\n".join(html_rows) + "\n
" + def normalize_money_glyphs(text: str) -> str: if not text: return text @@ -443,5945 +290,191 @@ def normalize_money_glyphs(text: str) -> str: t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t) return t -# ---- Generic HTML table normalizer ---- -class TableGridParser(HTMLParser): - """Parse a into rows of (text, colspan, rowspan).""" - - def __init__(self): - super().__init__() - self.rows = [] - self._current_row = [] - self._cell_text = [] - self._colspan = 1 - self._rowspan = 1 - self._in_cell = False - - def handle_starttag(self, tag, attrs): - if tag == "tr": - self._current_row = [] - elif tag in ("td", "th"): - attrs_d = dict(attrs) - self._colspan = max(1, int(attrs_d.get("colspan", 1))) - self._rowspan = max(1, int(attrs_d.get("rowspan", 1))) - self._cell_text = [] - self._in_cell = True - - def handle_endtag(self, tag): - if tag in ("td", "th"): - text = "".join(self._cell_text).strip().replace("\n", " ") - self._current_row.append((text, self._colspan, self._rowspan)) - self._in_cell = False - elif tag == "tr": - self.rows.append(self._current_row) - - def handle_data(self, data): - if self._in_cell: - self._cell_text.append(data) - -def _build_grid(rows_data): - if not rows_data: - return [] - blocked = defaultdict(set) - grid = [] - - for r, row_cells in enumerate(rows_data): - grid.append([]) - col = 0 - for content, C, R in row_cells: - while col in blocked[r]: - grid[r].append("") - col += 1 - for k in range(C): - grid[r].append(content if k == 0 else "") - for k in range(1, R): - blocked[r + k].add(col) - col += C - - max_cols = max(len(row) for row in grid) if grid else 0 - for row in grid: - while len(row) < max_cols: - row.append("") - return grid - -def _grid_to_html(grid): - if not grid: - return "" - lines = ["
"] - for r, row in enumerate(grid): - lines.append("") - tag = "th" if r == 0 else "td" - for cell in row: - escaped = ( - (cell or "") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - ) - lines.append(f"<{tag}>{escaped}") - lines.append("") - lines.append("
") - return "\n".join(lines) - -def _is_amount_like(s: str) -> bool: - if not s: - return False - return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None - -def _drop_truly_empty_columns(grid): - if not grid or len(grid) < 1: - return grid - - header = [str(c or "").strip() for c in grid[0]] - ncols = len(header) - if ncols <= 1: - return grid - - body = grid[1:] - keep = [True] * ncols - - for i in range(ncols): - if header[i] != "": - continue - total = 0 - non_empty = 0 - for row in body: - if i >= len(row): - continue - total += 1 - if str(row[i] or "").strip(): - non_empty += 1 - if total > 0 and (non_empty / total) <= 0.05: - keep[i] = False - - if all(keep): - return grid - - new_grid = [] - for row in grid: - new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]]) - return new_grid - - -def _is_multigroup_check_list_header(header: List[str]) -> bool: - """ - Wide check listings (e.g. YOUR CHECKS SEQUENCED) use one row with a title cell, repeated - Amount headers, and blank
slots between DATE | CHECK # | AMOUNT triplets. Fix 3 - blank-header merge would otherwise fuse the next column into Amount or the section title, - leaving only the first date column visibly populated. - """ - if not header: - return False - joined = " ".join(str(c or "").strip() for c in header).upper() - if "SEQUENCED" in joined and "CHECK" in joined: - return True - # Repeated Amount columns in one header row = multi-group layout; do not merge blanks. - if sum(1 for c in header if str(c or "").strip().upper() == "AMOUNT") >= 2: - return True - return False - - -def _should_skip_blank_header_merge_multiline_register(grid) -> bool: - """ - Some banks (e.g. First Horizon) use a title row with blank cells, then a second header - row with DATE DESCRIPTION | DEPOSIT | WITHDRAWAL | CARD #. _merge_blank_header_text_columns - would treat those blanks as OCR splits and merge columns — corrupting the grid. - """ - if not grid or len(grid) < 2: - return False - h0_join = " ".join(str(c or "").strip() for c in grid[0]).upper() - if "ACCOUNT HISTORY" in h0_join: - return True - for ri in range(1, min(5, len(grid))): - cells = [str(c or "").strip().upper() for c in grid[ri]] - if not any(cells): - continue - row_join = " ".join(cells) - if "DEPOSIT" in row_join and "WITHDRAWAL" in row_join: - if "DATE" in row_join or "DESCRIPTION" in row_join: - return True - return False - - -def _merge_blank_header_text_columns(grid): - if not grid or len(grid) < 2: - return grid - - header = [str(c or "").strip() for c in grid[0]] - if _is_multigroup_check_list_header(header): - return grid - if _should_skip_blank_header_merge_multiline_register(grid): - return grid - - ncols = len(header) - body = grid[1:] - blank_cols = [i for i, h in enumerate(header) if h == ""] - if not blank_cols: - return grid - - keep = [True] * ncols - - for i in blank_cols: - j = i - 1 - while j >= 0 and header[j] == "": - j -= 1 - if j < 0: - continue - - total = 0 - non_empty = 0 - texty = 0 - for row in body: - if i >= len(row) or j >= len(row): - continue - v = str(row[i] or "").strip() - total += 1 - if v: - non_empty += 1 - if not _is_amount_like(v): - texty += 1 - - if total == 0: - continue - non_empty_ratio = non_empty / total - texty_ratio = (texty / non_empty) if non_empty else 0.0 - if non_empty_ratio >= 0.55 and texty_ratio >= 0.70: - for r in range(1, len(grid)): - row = grid[r] - if i >= len(row) or j >= len(row): - continue - left = str(row[j] or "").strip() - right = str(row[i] or "").strip() - if right: - row[j] = (left + " " + right).strip() if left else right - keep[i] = False - - if all(keep): - return grid - - new_grid = [] - for row in grid: - new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]]) - return new_grid - - -def _split_wide_checks_sequenced_grid(grid: List[List[str]]) -> Optional[List[List[str]]]: - """ - Fix MD-4: Banks print YOUR CHECKS SEQUENCED as 2–3 side-by-side DATE|CHECK #|AMOUNT groups per row. - OCR HTML keeps that as 6–9 columns; narrow markdown previews only show the first date column. - Unfold into a single standard 3-column table (one row per check). - """ - if not grid or len(grid) < 2: - return None - header = [str(c or "").strip() for c in grid[0]] - header_join = " ".join(header).upper() - if "SEQUENCED" not in header_join or "CHECK" not in header_join: - return None - ncols = max(len(r) for r in grid if isinstance(r, list)) - if ncols < 6 or ncols % 3 != 0: - return None - amt_cols = sum(1 for c in header if str(c or "").strip().upper() == "AMOUNT") - if amt_cols < 2 and ncols < 9: - return None - - date_re = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$") - out: List[List[str]] = [["DATE", "CHECK #", "AMOUNT"]] - - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - cells = cells[:ncols] - c0 = cells[0] if cells else "" - joined = " ".join(cells) - - if c0.startswith("=") and ("=" * 3 in c0 or len(c0) >= 8): - continue - if c0 and "=" in c0 and not c0.replace("=", "").strip(): - continue - if "DATE" in c0.upper() and "CHECK" in c0.upper() and "AMOUNT" in c0.upper(): - continue - if "GAP IN CHECK" in joined.upper() and "SEQUENCE" in joined.upper(): - continue - - for g in range(0, ncols, 3): - if g + 2 >= len(cells): - break - d, ck, am = cells[g], cells[g + 1], cells[g + 2] - if not d and not ck and not am: - continue - if d and date_re.match(d): - out.append([d, ck, am]) - - if len(out) < 2: - return None - return out - - -def _fill_headless_trailing_amount_headers(grid): - """ - Fix 32a: PDF layouts often omit a header above the rightmost amount column; OCR leaves a - trailing empty . Downstream parsers then mis-associate columns. Fill only when the - column is predominantly strict currency (generic, bank-agnostic). - """ - if not grid or len(grid) < 2: - return grid - - header = [str(c or "").strip() for c in grid[0]] - ncols = len(header) - if ncols < 2: - return grid - - i = ncols - 1 - while i >= 0 and header[i] == "": - i -= 1 - empty_tail_indices = list(range(i + 1, ncols)) - if not empty_tail_indices: - return grid - - body = grid[1:] - new_header = list(header) - - for j in empty_tail_indices: - total = 0 - amt_hits = 0 - for row in body: - if j >= len(row): - continue - total += 1 - v = str(row[j] or "").strip() - if v and _is_amount_like(v): - amt_hits += 1 - if total == 0: - continue - if (amt_hits / total) < 0.45: - continue - - left = j - 1 - while left >= 0 and not new_header[left]: - left -= 1 - prev = (new_header[left] if left >= 0 else "").strip() - pl = prev.lower() - if any( - k in pl - for k in ( - "addition", - "subtraction", - "credit", - "debit", - "deposit", - "withdrawal", - ) - ): - new_header[j] = prev - else: - new_header[j] = "Amount" - - grid[0] = new_header - return grid - - -_MMDD_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{2}(?:[-/]\d{2,4})?$") - -# Leading transaction-type cell when OCR splits category vs merchant (East West / similar). -_STACKED_TXN_TYPE_RE = re.compile( - r"^(?:Pre-Auth Credit|Pre-Auth Debit|Preauth Debit|" - r"Withdrawal Trns|Withdrawal(?:\s|$)|Deposit(?:\s|$)|Deposit Transfe|" - r"Outgoing Wire|Incoming Wire|" - r"Service Charge|ACH Debit|ACH Credit|ACH Payment|ACH PMT|" - r"Wire Transfer|POS Purchase|ATM Withdrawal|Transfer Trns)\b", - re.IGNORECASE | re.DOTALL, -) - - -def _normalize_date_desc_money_table_alignment(grid): - """ - Fix 32b: Align OCR tables with common bank PDF layouts. - - * Credits (Additions): Number | Date | Transaction Description | Additions — Number is often - blank in the PDF but must exist as a column; OCR may omit it or split description across - columns and park amounts under a bogus \"Amount\" header. - * Debits (Subtractions): PDF is usually Date | Description | Subtractions (no Number) — - when OCR uses the same 4-column split error, merge back to three columns. - - Handles (a) Date | short type | merchant tail | amount, (b) Date | full desc | empty | amount, - (c) continuation rows with a blank date. Per-row merge (no single global mode) so mixed OCR - errors (e.g. wrapped merchant lines) still normalize. - """ - if not grid or len(grid) < 3: - return grid - - header = [str(c or "").strip() for c in grid[0]] - n = len(header) - - def _pad_row(cells, width): - cells = [str(c or "").strip() for c in cells] - while len(cells) < width: - cells.append("") - return cells[:width] - - h0n = re.sub(r"\s+", " ", header[0]).strip().lower() - h1n = re.sub(r"\s+", " ", header[1]).strip().lower() if n > 1 else "" - h2n = re.sub(r"\s+", " ", header[2]).strip().lower() if n > 2 else "" - h3n = re.sub(r"\s+", " ", header[3]).strip().lower() if n > 3 else "" - - money_label = header[2].strip() if n > 2 else "" - want_number_col = "addition" in h2n - - # --- 3-column Date | Transaction Description | Additions/Subtractions: prepend Number for credits only --- - if n == 3: - if h0n != "date" or "transaction" not in h1n or "description" not in h1n: - return grid - if not any( - k in h2n - for k in ( - "addition", - "subtraction", - "credit", - "debit", - "deposit", - "withdrawal", - ) - ): - return grid - if not want_number_col: - return grid - out = [["Number", header[0], header[1], money_label or "Additions"]] - for row in grid[1:]: - c = _pad_row(row, 3) - if not any(c): - out.append(["", "", "", ""]) - continue - out.append(["", c[0], c[1], c[2]]) - return out - - if n != 4: - return grid - - if h0n != "date": - return grid - if "transaction" not in h1n or "description" not in h1n: - return grid - if not any( - k in h2n - for k in ( - "addition", - "subtraction", - "credit", - "debit", - "deposit", - "withdrawal", - ) - ): - return grid - if h3n not in ("", "amount") and not any( - k in h3n for k in ("addition", "subtraction", "credit", "debit") - ): - return grid - - data_rows = [r for r in grid[1:] if any(str(c or "").strip() for c in r)] - if not data_rows: - return grid - - has_date_led = False - for row in data_rows: - c = _pad_row(row, 4) - if c[0] and _MMDD_DATE_RE.match(c[0]): - has_date_led = True - break - if not has_date_led: - return grid - - def _merge_date_led_row(c0, c1, c2, c3): - split_ok = ( - bool(c1) - and bool(_STACKED_TXN_TYPE_RE.match(c1)) - and bool(c2) - and not _is_amount_like(c2) - and _is_amount_like(c3) - ) - if split_ok: - return (c1 + " " + c2).strip(), c3 - shift_ok = ( - bool(c1) - and not _is_amount_like(c1) - and not (c2 or "").strip() - and _is_amount_like(c3) - ) - if shift_ok: - return c1, c3 - dep_ok = ( - bool(c1) - and bool(_STACKED_TXN_TYPE_RE.match(c1)) - and not (c2 or "").strip() - and _is_amount_like(c3) - ) - if dep_ok: - return c1, c3 - desc = ((c1 + " " + c2).strip() or c1).strip() - amt = c3 if _is_amount_like(c3) else "" - return desc, amt - - ml = money_label or ("Additions" if want_number_col else "Subtractions") - - if want_number_col: - out_h = ["Number", header[0], header[1], ml] - else: - out_h = [header[0], header[1], ml] - - out = [out_h] - for row in grid[1:]: - c = _pad_row(row, 4) - c0, c1, c2, c3 = c[0], c[1], c[2], c[3] - if not any(c): - out.append([""] * len(out_h)) - continue - if c0 and _MMDD_DATE_RE.match(c0): - desc, amt = _merge_date_led_row(c0, c1, c2, c3) - if want_number_col: - out.append(["", c0, desc, amt]) - else: - out.append([c0, desc, amt]) - continue - if want_number_col: - desc_t = (c1 + " " + c2).strip() - amt_t = c3 if _is_amount_like(c3) else "" - out.append(["", c0, desc_t, amt_t]) +def light_stabilize_markdown(page_md: str) -> str: + """Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; repair tags.""" + if not page_md: + return page_md + page_md = normalize_money_glyphs(page_md) + blocks = re.split(r"\n\s*\n", page_md.strip()) + out_blocks = [] + for b in blocks: + if looks_like_markdown_table(b): + out_blocks.append(md_table_to_html(b)) else: - desc_t = (c1 + " " + c2).strip() - amt_t = c3 if _is_amount_like(c3) else "" - out.append([c0, desc_t, amt_t]) - - return out - - -# -------------------------- -# Shared keyword definitions -# -------------------------- - -_MONEY_RE = re.compile( - r"^(?:[\$£€¥]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$", - re.IGNORECASE, -) - -_HEADER_KW_PATTERNS = [ - r"DATE", - r"POSTING\s+DATE", - r"VALUE\s+DATE", - r"TXN\s+DATE", - r"TRANSACTION\s+DATE", - r"ENTRY\s+DATE", - r"EFFECTIVE\s+DATE", - r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?", - r"TXN(?:\s+(?:ID|NO|TYPE))?", - r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?", - r"CHEQUE(?:\s*(?:NO|NUMBER))?", - r"CHQ(?:\s*(?:NO|NUMBER))?", - r"VOUCHER(?:\s*(?:NO|NUMBER))?", - r"SR\.?\s*NO\.?", - r"SERIAL(?:\s*(?:NO|NUMBER))?", - r"DESCRIPTION", - r"DETAILS?", - r"PARTICULARS?(?:\s+OF\s+TRANSACTION)?", - r"NARRATION", - r"REMARKS?", - r"NOTES?", - r"DEBIT", - r"DEBITS?", - r"DR\.?", - r"WITHDRAWALS?", - r"PAID\s+OUT", - r"MONEY\s+OUT", - r"CREDIT", - r"CREDITS?", - r"CR\.?", - r"DEPOSITS?", - r"PAID\s+IN", - r"MONEY\s+IN", - r"AMOUNT", - r"BALANCE", - r"BAL\.?", - r"RUNNING\s+BALANCE", - r"RUNNING\s+BAL\.?", - r"AVAILABLE\s+BALANCE", - r"AVAILABLE\s+BAL\.?", - r"AVAIL\.?\s+BAL\.?", - r"LEDGER\s+BALANCE", - r"LEDGER\s+BAL\.?", - r"CLOSING\s+BALANCE", - r"CLOSING\s+BAL\.?", - r"OPENING\s+BALANCE", - r"OPENING\s+BAL\.?", -] - -_HEADER_KW_RES = [ - re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL) - for p in _HEADER_KW_PATTERNS -] - -_HEADER_KW_EXACT_RE = re.compile( - r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$", - re.IGNORECASE, -) - -_HEADER_ROW_KEYWORD_THRESHOLD = 0.5 - -# -------------------------- -# Fix 1: Fused-header recovery -# -------------------------- - -def _split_keyword_remainder(cell_text: str): - s = cell_text.strip() - for pattern in _HEADER_KW_RES: - m = pattern.match(s) - if m: - keyword = m.group(1).strip() - remainder = m.group(2).strip() - return keyword, remainder - return s, "" - -def _extract_fused_header_artifacts(header_row): - if not header_row: - return list(header_row), None - - ncols = len(header_row) - cleaned = list(header_row) - recovered = [""] * ncols - - text_fused = [] - money_fused = [] - - for idx, cell in enumerate(header_row): - cell_s = str(cell or "").strip() - if not cell_s: - continue - - keyword, remainder = _split_keyword_remainder(cell_s) - - if not remainder: - continue - - remainder_no_space = remainder.replace(" ", "") - - if _MONEY_RE.match(remainder_no_space): - cleaned[idx] = keyword - recovered[idx] = remainder - money_fused.append(idx) - - elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""): - cleaned[idx] = keyword - recovered[idx] = remainder - text_fused.append(idx) - - if text_fused and money_fused: - return cleaned, recovered - - return list(header_row), None + out_blocks.append(b) + return close_unclosed_html("\n\n".join(out_blocks)) -def _clean_header_artifacts(grid): - if not grid or not grid[0]: - return grid, None - cleaned_header, recovered_row = _extract_fused_header_artifacts(grid[0]) - grid[0] = cleaned_header - return grid, recovered_row +def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]: + import pymupdf as fitz + from PIL import Image, ImageEnhance -# -------------------------- -# Fix 2: Misplaced header row promotion -# -------------------------- + doc = fitz.open(pdf_path) + page_images: List[str] = [] + page_heights: List[int] = [] -def _row_keyword_score(row): - non_empty = [str(c or "").strip() for c in row if str(c or "").strip()] - if not non_empty: - return 0.0 + for i in range(len(doc)): + page = doc[i] + pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False) - keyword_hits = 0 - for cell in non_empty: - if _HEADER_KW_EXACT_RE.match(cell): - keyword_hits += 1 - continue - parts = cell.split() - if all(_HEADER_KW_EXACT_RE.match(p) for p in parts) and len(parts) > 1: - keyword_hits += 1 - continue + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) - return keyword_hits / len(non_empty) + if ENABLE_CONTRAST: + img = ImageEnhance.Contrast(img).enhance(1.12) -_BALANCE_INFO_RE = re.compile( - r"(beginning|ending|opening|closing)\s+balance" - r"|account\s*#?\s*\d{5,}" - r"|\b\d{7,}\b" - r"|\$\d{1,3}(?:,\d{3})*\.\d{2}", - re.IGNORECASE, -) + w, h = img.size + pad_l = int(w * PAD_LEFT_FRAC) + pad_r = int(w * PAD_RIGHT_FRAC) + pad_t = int(h * PAD_TOP_FRAC) + pad_b = int(h * PAD_BOTTOM_FRAC) -def _header_contains_balance_info(header_row): - for cell in header_row: - if _BALANCE_INFO_RE.search(str(cell or "")): - return True - return False + if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)): + canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255)) + canvas.paste(img, (pad_l, pad_t)) + img = canvas -def _promote_misplaced_header_row(grid): - if not grid or len(grid) < 2: - return grid + img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png") + img.save(img_path, "PNG", compress_level=6) + page_images.append(img_path) + page_heights.append(img.height) - current_header_score = _row_keyword_score(grid[0]) - if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD: - return grid + doc.close() + return page_images, page_heights - candidate_idx = None - candidate_score = 0.0 - for i in range(1, min(len(grid), 6)): - score = _row_keyword_score(grid[i]) - if score >= _HEADER_ROW_KEYWORD_THRESHOLD and score > candidate_score: - candidate_score = score - candidate_idx = i - if candidate_idx is None: - return grid +def get_page_md_and_regions(page_result): + md = "" + if hasattr(page_result, "markdown_result") and page_result.markdown_result: + md = (page_result.markdown_result or "").strip() + regions = [] + if hasattr(page_result, "json_result"): + jr = page_result.json_result + if isinstance(jr, dict) and "regions" in jr: + regions = jr.get("regions") or [] + elif isinstance(jr, list) and len(jr) > 0: + r = jr[0] if isinstance(jr[0], list) else jr + if isinstance(r, list): + regions = r + elif isinstance(r, dict) and "regions" in r: + regions = r.get("regions") or [] + return md, regions - candidate_row = grid[candidate_idx] - expanded_header = [] - for cell in candidate_row: - cell_s = str(cell or "").strip() - parts = cell_s.split() - if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts): - expanded_header.extend(parts) - else: - expanded_header.append(cell_s) +def run_ocr(uploaded_file): + if uploaded_file is None: + return "Please upload a file." - new_ncols = len(expanded_header) - col_expansion = new_ncols - len(candidate_row) + page_images: List[str] = [] + try: + path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file) + is_pdf = path.lower().endswith(".pdf") + parser = get_parser() - metadata_row = None - if _header_contains_balance_info(grid[0]): - meta_cells = [str(c or "").strip() for c in grid[0]] - meta_text = " ".join(c for c in meta_cells if c).strip() - if meta_text: - metadata_row = [meta_text] + [""] * (new_ncols - 1) + page_heights: List[int] = [] - new_data_rows = [] - for row in grid[candidate_idx + 1:]: - if col_expansion > 0 and row: - first_cell = str(row[0] or "").strip() - date_match = re.match(r"^(\d{1,2}/\d{1,2})\s+(.*)", first_cell, re.DOTALL) - if date_match and col_expansion == 1: - new_row = [date_match.group(1), date_match.group(2).strip()] + list(row[1:]) - else: - new_row = list(row) + [""] * col_expansion - new_data_rows.append(new_row) + if is_pdf: + page_images, page_heights = render_pdf_pages_to_images(path) + results = parser.parse(page_images) else: - new_data_rows.append(list(row)) + page_images = [path] + page_heights = [1000] + results = parser.parse(path) - def pad(row, n): - r = list(row) - while len(r) < n: - r.append("") - return r[:n] + if not isinstance(results, list): + results = [results] - new_grid = [pad(expanded_header, new_ncols)] - if metadata_row is not None: - new_grid.append(pad(metadata_row, new_ncols)) - for row in new_data_rows: - new_grid.append(pad(row, new_ncols)) + all_pages = [] + for page_num, page_result in enumerate(results): + page_md, regions = get_page_md_and_regions(page_result) + img_h = page_heights[page_num] if page_num < len(page_heights) else 1000 + header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h) - return new_grid + he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC + fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC) -# -------------------------- -# Fix 3: Fused key-value rows in summary sections -# -------------------------- + he = max(0.02, min(0.25, he)) + fs = max(0.75, min(0.98, fs)) -_FUSED_KV_RE = re.compile( - r"^(.+?)(-?\d{1,3}(?:,\d{3})*(?:\.\d+)?%?)$", - re.DOTALL, -) + parts = [] -def _fix_fused_keyvalue_rows(grid): - if not grid or len(grid) < 2: - return grid + hdr = "" + if is_pdf: + hdr = extract_zone_text_pdf(path, page_num, 0, he) + if not (hdr and hdr.strip()): + hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC) + if not (hdr and hdr.strip()) and page_num < len(page_images): + hdr = ocr_zone(page_images[page_num], 0, he) + if hdr and hdr.strip(): + parts.append(light_stabilize_markdown(fix_account_number(normalize_money_glyphs(hdr.strip())))) - _DOLLAR_AMOUNT_RE = re.compile(r"\$\s*\d{1,3}(?:,\d{3})*\.\d{2}") - _LONG_NUMBER_RE = re.compile(r"\d{7,}") + if page_md and page_md.strip(): + parts.append(light_stabilize_markdown(page_md.strip())) - ncols = len(grid[0]) - new_grid = [grid[0]] + if ENABLE_FOOTER_OCR and page_num < len(page_images): + ftr = "" + if is_pdf: + ftr = extract_zone_text_pdf(path, page_num, fs, 1.0) + if not (ftr and ftr.strip()): + ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0) + if not (ftr and ftr.strip()): + ftr = ocr_zone(page_images[page_num], fs, 1.0) + if ftr and ftr.strip(): + ftr_clean = normalize_money_glyphs(ftr.strip()) - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - if not cells: - new_grid.append(row) - continue + ftr_first_line = next( + (ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()), + "", + ) + already_present = ftr_first_line and any( + ftr_first_line in part.lower() for part in parts + ) - first = cells[0] - rest_empty = all(c == "" for c in cells[1:]) + _footer_date_re = re.compile(r"\b\d{1,2}[-/]\d{2}\b") + _footer_amt_re = re.compile(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b") + _date_hits = len(_footer_date_re.findall(ftr_clean)) + _amt_hits = len(_footer_amt_re.findall(ftr_clean)) + is_txn_dump = _date_hits >= 3 and _amt_hits >= 3 - if not rest_empty or not first: - new_grid.append(row) - continue + if not already_present and not is_txn_dump: + parts.append(ftr_clean) - if _DOLLAR_AMOUNT_RE.search(first): - new_grid.append(row) - continue + if parts: + all_pages.append("\n\n".join(parts)) - if _LONG_NUMBER_RE.search(first): - new_grid.append(row) - continue + merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)" + return merged - if re.search(r"#\s*[\dX]", first): - new_grid.append(row) - continue + except Exception as e: + import traceback - m = _FUSED_KV_RE.match(first) - if not m: - new_grid.append(row) - continue + log.exception("run_ocr failed: %s", e) + return f"Error: {e}\n\n{traceback.format_exc()}" - label_raw = m.group(1) - value = m.group(2).strip() + finally: + for p in page_images: + try: + if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p): + os.unlink(p) + except Exception: + pass - if label_raw != label_raw.rstrip(): - new_grid.append(row) - continue - label = label_raw.strip() +def _create_gradio_demo(): + import gradio as gr - if not label or not value: - new_grid.append(row) - continue - - new_row = [""] * ncols - new_row[0] = label - new_row[ncols - 1] = value - new_grid.append(new_row) - - return new_grid - -# -------------------------- -# Fix 4: PDF text-layer row-count patch -# -------------------------- - -def _extract_textlayer_rows(pdf_path: str, page_num: int): - try: - import pymupdf as fitz - - doc = fitz.open(pdf_path) - page = doc[page_num] - words = page.get_text("words") - doc.close() - - if not words: - return [] - - lines_by_y = {} - for w in words: - x0, y0, word = float(w[0]), float(w[1]), w[4] - bucket = None - for existing_y in lines_by_y: - if abs(existing_y - y0) <= 5: - bucket = existing_y - break - if bucket is None: - bucket = y0 - lines_by_y.setdefault(bucket, []).append((x0, word)) - - sorted_lines = [] - for y in sorted(lines_by_y): - line_words = sorted(lines_by_y[y], key=lambda t: t[0]) - sorted_lines.append([w for _, w in line_words]) - - _MONTHS = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" - date_re = re.compile( - r"^\d{1,2}/\d{2}(?:/\d{2,4})?$" - r"|^\d{1,2}-\d{2}(?:-\d{2,4})?$" - r"|^\d{4}-\d{2}-\d{2}$" - r"|^" + _MONTHS + r"$", - re.IGNORECASE, - ) - month_re = re.compile(r"^" + _MONTHS + r"$", re.IGNORECASE) - day_re = re.compile(r"^\d{1,2}$") - - amount_re = re.compile( - r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$" - r"|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$" - r"|^-\s+\$\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$" - ) - - rows = [] - for line in sorted_lines: - if len(line) < 2: - continue - - date_str = None - desc_start = None - - if date_re.match(line[0]): - if month_re.match(line[0]) and len(line) > 1 and day_re.match(line[1]): - date_str = line[0] + " " + line[1] - desc_start = 2 - else: - date_str = line[0] - desc_start = 1 - else: - continue - - if desc_start is None or desc_start >= len(line): - continue - - remaining = line[desc_start:] - post_date_str = "" - if remaining and month_re.match(remaining[0]): - if len(remaining) > 1 and day_re.match(remaining[1]): - post_date_str = remaining[0] + " " + remaining[1] - remaining = remaining[2:] - elif len(remaining) > 0: - remaining = remaining[1:] - elif remaining and date_re.match(remaining[0]) and not month_re.match(remaining[0]): - post_date_str = remaining[0] - remaining = remaining[1:] - - if len(remaining) < 2: - continue - - if (len(remaining) >= 2 - and remaining[-2] == "-" - and remaining[-1].startswith("$")): - amount_candidate = remaining[-2] + " " + remaining[-1] - desc_tokens = remaining[:-2] - else: - amount_candidate = remaining[-1] - desc_tokens = remaining[:-1] - - if not amount_re.match(amount_candidate): - continue - - desc = " ".join(desc_tokens).strip() - if not desc: - continue - - if not re.search(r"[A-Za-z]", desc): - continue - - rows.append({"date": date_str, "post_date": post_date_str, "desc": desc, "amount": amount_candidate}) - - return rows if len(rows) >= 2 else [] - - except Exception as e: - log.debug("_extract_textlayer_rows failed (page %d): %s", page_num, e) - return [] - -def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]: - if not text_layer: - return [] - m = re.search( - r"Checks\s*\([^\n]*\n(.*?)(?=^Daily\s+Account\s+Balance\s*$)", - text_layer, - re.DOTALL | re.MULTILINE | re.IGNORECASE, - ) - if not m: - return [] - chunk = m.group(1) - chunk = re.sub( - r"^\s*Date\s+Number\s+Amount\s*$", - "", - chunk, - flags=re.MULTILINE | re.IGNORECASE, - ) - _triplet = re.compile( - r"(\d{2}-\d{2})\s+(\*?\d+)\s+(\d{1,3}(?:,\d{3})*\.\d{2})" - ) - out: List[Tuple[str, str, str]] = [] - seen = set() - for t in _triplet.findall(chunk): - if t not in seen: - seen.add(t) - out.append(t) - return out - -def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str: - if not text_layer: - return "" - - _DATE_RE = re.compile(r"^\d{2}-\d{2}$") - _MONEY_RE = re.compile(r"^\d{1,3}(?:,\d{3})*\.\d{2}$") - _CHECK_RE = re.compile(r"^\*?\d+$") - - lines = [l.strip() for l in text_layer.splitlines()] - output = [] - - if needs_checks: - checks = [] - i = 0 - while i < len(lines): - if lines[i].lower().startswith("checks"): - i += 1 - while i < len(lines): - if "daily account balance" in lines[i].lower(): - break - if lines[i] == "Date": - i += 1 - dates = [] - while i < len(lines) and _DATE_RE.match(lines[i]): - dates.append(lines[i]); i += 1 - while i < len(lines) and lines[i] != "Number": - i += 1 - i += 1 - numbers = [] - while i < len(lines): - if _CHECK_RE.match(lines[i]): - numbers.append(lines[i]); i += 1 - elif lines[i] == "": - i += 1 - else: - break - while i < len(lines) and lines[i] != "Amount": - i += 1 - i += 1 - amounts = [] - while i < len(lines): - if _MONEY_RE.match(lines[i]): - amounts.append(lines[i]); i += 1 - elif lines[i] == "": - i += 1 - else: - break - for d, n, a in zip(dates, numbers, amounts): - checks.append((d, n, a)) - else: - i += 1 - break - i += 1 - - if not checks and re.search(r"\bChecks\s*\(", text_layer, re.I): - checks = _jb_extract_checks_fallback(text_layer) - - if checks: - rows = "\n".join( - "
" + d + "" + n + "" + a + "
\n\n' - + rows + "\n
DateNumberAmount
" - ) - - if needs_dab: - balances = [] - i = 0 - while i < len(lines): - if "daily account balance" in lines[i].lower(): - i += 1 - while i < len(lines): - if lines[i] == "Date": - i += 1 - dates = [] - while i < len(lines) and _DATE_RE.match(lines[i]): - dates.append(lines[i]); i += 1 - while i < len(lines) and lines[i] != "Balance": - i += 1 - i += 1 - bals = [] - while i < len(lines): - if _MONEY_RE.match(lines[i]): - bals.append(lines[i]); i += 1 - elif lines[i] == "": - i += 1 - else: - break - for d, b in zip(dates, bals): - balances.append((d, b)) - else: - i += 1 - break - i += 1 - - if balances: - rows = "\n".join( - "" + d + "" + b + "" - for d, b in balances - ) - output.append("Daily Account Balance") - output.append( - "\n\n" - + rows + "\n
DateBalance
" - ) - - return "\n\n".join(output) - -def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str: - if not page_md or "]*>.*?", re.DOTALL | re.IGNORECASE) - result = page_md - - for tbl_match in table_pattern.finditer(page_md): - table_html = tbl_match.group(0) - - p = TableGridParser() - p.feed(table_html) - grid = _build_grid(p.rows) - if len(grid) < 2: - continue - - header = [str(c or "").strip().upper() for c in grid[0]] - date_col = desc_col = amt_col = None - for ci, h in enumerate(header): - if re.search(r"\bDATE\b", h) and date_col is None: - date_col = ci - elif re.search(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?)\b", h) and desc_col is None: - desc_col = ci - elif re.search(r"\b(AMOUNT|DEBIT|CREDIT|DR|CR)\b", h) and amt_col is None: - amt_col = ci - - if date_col is None or desc_col is None or amt_col is None: - continue - - ocr_freq = {} - ocr_rows_indexed = [] - for ri, row in enumerate(grid[1:], start=1): - cells = [str(c or "").strip() for c in row] - if len(cells) <= max(date_col, desc_col, amt_col): - continue - d = _norm(cells[date_col]) - desc = _norm(cells[desc_col]) - amt = _norm(cells[amt_col]) - if not d or not desc: - continue - key = (d, desc, amt) - ocr_freq[key] = ocr_freq.get(key, 0) + 1 - ocr_rows_indexed.append((ri, key)) - - overlap = set(ocr_freq.keys()) & set(tl_freq.keys()) - - _ocr_dates = [ - _norm(str(row[date_col] or "")) - for row in grid[1:] - if len(row) > date_col and str(row[date_col] or "").strip() - ] - _tl_dates = [_norm(r["date"]) for r in tl_rows] - _slash_re = re.compile(r"^\d{1,2}/\d{2}") - _hyphen_re = re.compile(r"^\d{1,2}-\d{2}") - def _date_fmt(dates): - if any(_slash_re.match(d) for d in dates): return "slash" - if any(_hyphen_re.match(d) for d in dates): return "hyphen" - return "other" - ocr_fmt = _date_fmt(_ocr_dates) - tl_fmt = _date_fmt(_tl_dates) - date_fmt_match = (ocr_fmt == tl_fmt) or "other" in (ocr_fmt, tl_fmt) - - to_inject = {} - to_inject_new = {} - - for key in tl_freq: - ocr_count = ocr_freq.get(key, 0) - tl_count = tl_freq[key] - if tl_count > ocr_count: - missing = tl_count - ocr_count - if ocr_count > 0: - if overlap: - to_inject[key] = missing - else: - if date_fmt_match: - tl_row_data = next( - (r for r in tl_rows - if (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"])) == key), - None - ) - if tl_row_data: - to_inject_new[key] = (missing, tl_row_data) - - if not to_inject and not to_inject_new: - continue - - new_grid = [grid[0]] - for ri, row in enumerate(grid[1:], start=1): - new_grid.append(row) - cells = [str(c or "").strip() for c in row] - if len(cells) <= max(date_col, desc_col, amt_col): - continue - d = _norm(cells[date_col]) - desc = _norm(cells[desc_col]) - amt = _norm(cells[amt_col]) - key = (d, desc, amt) - if key in to_inject and to_inject[key] > 0: - last_occ = max(idx for idx, k in ocr_rows_indexed if k == key) - if ri == last_occ: - for _ in range(to_inject[key]): - new_grid.append(list(row)) - to_inject[key] = 0 - - new_table_html = _grid_to_html(new_grid) - result = result.replace(table_html, new_table_html, 1) - - if to_inject_new: - ncols = len(grid[0]) - header_row = grid[0] - - post_date_col = None - for ci, h in enumerate(header_row): - hh = str(h or "").strip().upper() - if "POST" in hh and "DATE" in hh: - post_date_col = ci - break - - new_rows = [] - for key, (count, tl_row_data) in to_inject_new.items(): - for _ in range(count): - new_row = [""] * ncols - new_row[date_col] = tl_row_data["date"] - new_row[desc_col] = tl_row_data["desc"] - new_row[amt_col] = tl_row_data["amount"] - if post_date_col is not None: - new_row[post_date_col] = tl_row_data.get("post_date", "") - new_rows.append(new_row) - log.info( - "page %d: new table row from text layer: %s %s", - page_num, tl_row_data["date"], tl_row_data["desc"][:40] - ) - - if new_rows: - extra_grid = [header_row] + new_rows - extra_html = "\n\n" + _grid_to_html(extra_grid) - insert_pos = result.find(new_table_html) + len(new_table_html) - result = result[:insert_pos] + extra_html + result[insert_pos:] - - if tl_rows: - _has_txn_table = False - for _tbl in table_pattern.finditer(result): - _p2 = TableGridParser() - _p2.feed(_tbl.group(0)) - _g2 = _build_grid(_p2.rows) - if len(_g2) < 2: - continue - _h2 = [str(c or "").strip().upper() for c in _g2[0]] - if (any(re.search(r"\bDATE\b", x) for x in _h2) and - any(re.search(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?)\b", x) for x in _h2)): - _has_txn_table = True - break - - if not _has_txn_table: - _has_post = any(r.get("post_date") for r in tl_rows) - if _has_post: - _chdr = ["Date", "Post Date", "Transaction Description", "Amount"] - _di2, _pi2, _xi2, _ai2 = 0, 1, 2, 3 - else: - _chdr = ["Date", "Transaction Description", "Amount"] - _di2, _xi2, _ai2 = 0, 1, 2 - _cnew_rows = [] - for r in tl_rows: - _crow = [""] * len(_chdr) - _crow[_di2] = r["date"] - _crow[_xi2] = r["desc"] - _crow[_ai2] = r["amount"] - if _has_post: - _crow[_pi2] = r.get("post_date", "") - _cnew_rows.append(_crow) - result = _grid_to_html([_chdr] + _cnew_rows) + "\n\n" + result - log.info("page %d: Case C — new table (%d rows) from text layer", - page_num, len(_cnew_rows)) - - return result - - except Exception as e: - log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e) - _tl_freq_context.clear() - return page_md - -# -------------------------- -# Fix 5: Mid-table separator row reconstruction -# -------------------------- - -_DATE_RE = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$") -_SPLIT_AMOUNT_RE = re.compile( - r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$" -) - -def _reconstruct_separator_rows(grid): - if not grid or len(grid) < 2: - return grid - - ncols = len(grid[0]) - if ncols < 2: - return grid - - new_grid = [grid[0]] - - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - - non_empty = [(i, c) for i, c in enumerate(cells) if c] - - if len(non_empty) == 0: - new_grid.append(row) - continue - - first_idx, first_val = non_empty[0] - - if _DATE_RE.match(first_val): - new_grid.append(row) - continue - - if not re.search(r"[A-Za-z]", first_val): - new_grid.append(row) - continue - - if len(non_empty) == 1: - new_row = [""] * ncols - new_row[0] = first_val - new_grid.append(new_row) - continue - - trailing = [val for _, val in non_empty[1:]] - all_trailing_are_digit_fragments = all( - re.fullmatch(r"\d{1,4}", v) for v in trailing - ) - - if not all_trailing_are_digit_fragments: - new_grid.append(row) - continue - - reconstructed = "".join(val for _, val in non_empty).strip() - new_row = [""] * ncols - new_row[0] = reconstructed - new_grid.append(new_row) - - return new_grid - -# -------------------------- -# Normalizer entry point -# -------------------------- - -def _merge_split_rows(grid): - if not grid or len(grid) < 3: - return grid - - ncols = len(grid[0]) - if ncols < 3: - return grid - - header = [str(c or "").strip().upper() for c in grid[0]] - date_col = desc_col = bal_col = None - debit_cols = [] - credit_cols = [] - - for ci, h in enumerate(header): - if re.search(r"\bDATE\b", h) and date_col is None: - date_col = ci - if re.search(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?|TRANSACTION)\b", h) and desc_col is None: - desc_col = ci - if re.search(r"\bBALANCE\b", h) and bal_col is None: - bal_col = ci - if re.search(r"\b(DEBIT|DEBITS|DR|WITHDRAWAL|SUBTRACTIONS?)\b", h): - debit_cols.append(ci) - if re.search(r"\b(CREDIT|CREDITS|CR|DEPOSIT|ADDITIONS?)\b", h): - credit_cols.append(ci) - - if date_col is None or desc_col is None: - return grid - if not debit_cols and not credit_cols: - return grid - - money_cols = debit_cols + credit_cols - _MONEY_RE2 = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{1,4}$") - _DATE_RE2 = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$|^\d{1,2}-\d{2}(?:-\d{2,4})?$") - _FRAGMENT_RE = re.compile(r"^[^\s\.]{1,6}$") - - new_grid = [grid[0]] - i = 1 - while i < len(grid): - row = [str(c or "").strip() for c in grid[i]] - while len(row) < ncols: - row.append("") - - has_desc = bool(row[desc_col]) - no_date = not row[date_col] - no_money = all(not row[c] for c in money_cols) - bal_is_frag = (bal_col is not None and - (_FRAGMENT_RE.match(row[bal_col]) or not row[bal_col])) - - if has_desc and no_date and no_money and bal_is_frag and (i + 1) < len(grid): - next_row = [str(c or "").strip() for c in grid[i + 1]] - while len(next_row) < ncols: - next_row.append("") - - next_has_date = bool(next_row[date_col]) and _DATE_RE2.match(next_row[date_col]) - next_has_money = any(bool(next_row[c]) for c in money_cols) - - if next_has_date and next_has_money: - merged = list(next_row) - if next_row[desc_col]: - merged[desc_col] = row[desc_col] + " " + next_row[desc_col] - else: - merged[desc_col] = row[desc_col] - new_grid.append(merged) - i += 2 - continue - - new_grid.append(row) - i += 1 - - return new_grid - -def _extract_fused_desc_amount(grid): - if not grid or len(grid) < 2: - return grid - - ncols = len(grid[0]) - header = [str(c or "").strip().upper() for c in grid[0]] - - date_col = desc_col = None - debit_cols = [] - credit_cols = [] - for ci, h in enumerate(header): - if re.search(r"\bDATE\b", h) and date_col is None: - date_col = ci - if re.search(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?|TRANSACTION)\b", h) and desc_col is None: - desc_col = ci - if re.search(r"\b(DEBIT|DEBITS|DR|WITHDRAWAL|SUBTRACTIONS?)\b", h): - debit_cols.append(ci) - if re.search(r"\b(CREDIT|CREDITS|CR|DEPOSIT|ADDITIONS?)\b", h): - credit_cols.append(ci) - - if date_col is None or desc_col is None or not debit_cols: - return grid - - money_cols = debit_cols + credit_cols - _TRAILING_AMT = re.compile(r"^(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})$") - debit_col = debit_cols[0] - - new_grid = [grid[0]] - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - - desc = cells[desc_col] - no_money = all(not cells[c] for c in money_cols) - has_date = bool(cells[date_col]) - - if desc and no_money and has_date: - m = _TRAILING_AMT.match(desc) - if m: - cells = list(cells) - cells[desc_col] = m.group(1).strip() - cells[debit_col] = m.group(2) - new_grid.append(cells) - continue - - new_grid.append(cells) - - return new_grid - -def _is_junk_table(grid): - if not grid or len(grid) < 2: - return False - ncols = len(grid[0]) - if ncols != 1: - return False - header_text = str(grid[0][0] or "").strip().upper() - if "CUSTOMER INFORMATION" in header_text: - data_rows = grid[1:] - if all(len(str(r[0] or "").strip()) <= 10 for r in data_rows): - return True - return False - -def _split_fused_multicolumn_header(grid): - if not grid or len(grid) < 2: - return grid - - ncols = len(grid[0]) - if ncols < 2: - return grid - - first_header = str(grid[0][0] or "").strip() - parts = first_header.split() - kw_hits = [p for p in parts if _HEADER_KW_EXACT_RE.match(p)] - if len(kw_hits) < 2: - return grid - - rest_headers = [str(c or "").strip() for c in grid[0][1:]] - if not all(_HEADER_KW_EXACT_RE.match(h) for h in rest_headers if h): - return grid - - new_header_cols = [] - non_kw_parts = [] - for p in parts: - if _HEADER_KW_EXACT_RE.match(p): - if non_kw_parts: - new_header_cols.append(" ".join(non_kw_parts)) - non_kw_parts = [] - new_header_cols.append(p) - else: - non_kw_parts.append(p) - if non_kw_parts: - new_header_cols.append(" ".join(non_kw_parts)) - - new_header = new_header_cols + rest_headers - new_ncols = len(new_header) - extra_cols = new_ncols - ncols - - _DATE_COL_RE = re.compile(r"\bDATE\b", re.IGNORECASE) - _CARD_COL_RE = re.compile(r"\bCARD\b", re.IGNORECASE) - _DESC_COL_RE = re.compile(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?)\b", re.IGNORECASE) - _MONEY_COL_RE = re.compile(r"\b(DEPOSIT|WITHDRAWAL|DEBIT|CREDIT|AMOUNT|ADDITION|SUBTRACTION)S?\b", re.IGNORECASE) - - new_date_col = next((i for i,h in enumerate(new_header) if _DATE_COL_RE.search(h)), None) - new_desc_col = next((i for i,h in enumerate(new_header) if _DESC_COL_RE.search(h)), None) - new_card_col = next((i for i,h in enumerate(new_header) if _CARD_COL_RE.search(h)), None) - - if new_date_col is None or new_desc_col is None: - return grid - - _DATE_PREFIX = re.compile(r"^(\d{1,2}/\d{2}(?:/\d{2,4})?)\s+(.*)", re.DOTALL) - _CARD_SUFFIX = re.compile(r"^(.*?)\s+(\d{4})$", re.DOTALL) - - def pad(row, n): - r = list(row) - while len(r) < n: r.append("") - return r[:n] - - new_grid = [pad(new_header, new_ncols)] - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: cells.append("") - - fused_cell = cells[0] - rest_cells = cells[1:] - - date_val = "" - desc_val = fused_cell - card_val = "" - - dm = _DATE_PREFIX.match(fused_cell) - if dm: - date_val = dm.group(1) - desc_val = dm.group(2).strip() - - if new_card_col is not None: - cm = _CARD_SUFFIX.match(desc_val) - if cm: - desc_val = cm.group(1).strip() - card_val = cm.group(2) - - new_row = [""] * new_ncols - new_row[new_date_col] = date_val - new_row[new_desc_col] = desc_val - if new_card_col is not None: - new_row[new_card_col] = card_val - money_col_indices = [i for i,h in enumerate(new_header) if _MONEY_COL_RE.search(h)] - for mi, ri in enumerate(range(len(rest_cells))): - if mi < len(money_col_indices): - new_row[money_col_indices[mi]] = rest_cells[ri] - - new_grid.append(new_row) - - return new_grid - -def _normalize_daily_balance_table(grid): - if not grid or len(grid) < 2: - return grid - - header = [str(c or "").strip().upper() for c in grid[0]] - ncols = len(header) - - if ncols < 4 or ncols % 2 != 0: - return grid - - half = ncols // 2 - date_half = header[:half] - bal_half = header[half:] - - if not all(h == "DATE" for h in date_half): - return grid - if not all(h == "BALANCE" for h in bal_half): - return grid - - new_header = [] - for i in range(half): - new_header.append("DATE") - new_header.append("BALANCE") - - new_rows = [] - _DATE_RE3 = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$") - - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - - date_cells = [cells[0]] - bal_cells = cells[1:] - - first = date_cells[0] - tokens = re.split(r"[\n\r\s]+", first) - date_tokens = [t.strip() for t in tokens if t.strip() and _DATE_RE3.match(t.strip())] - if len(date_tokens) >= 2: - stacked = date_tokens - else: - stacked = date_cells - - new_row = [""] * ncols - for i, date_val in enumerate(stacked): - if not _DATE_RE3.match(date_val): - continue - bal_val = bal_cells[i] if i < len(bal_cells) else "" - new_row[i * 2] = date_val - new_row[i * 2 + 1] = bal_val - new_rows.append(new_row) - - if not new_rows: - return grid - - return [new_header] + new_rows - -def _normalize_multiline_register_header(grid): - """ - Normalize two-row register headers where row0 is mostly blank/Amount placeholders and - row1 carries the real labels (DATE, DESCRIPTION, DEPOSIT, WITHDRAWAL, CARD #). - """ - if not grid or len(grid) < 3: - return grid - r0 = [str(c or "").strip() for c in grid[0]] - r1 = [str(c or "").strip() for c in grid[1]] - if not r1: - return grid - - r1_join = " ".join(r1).upper() - if "DATE" not in r1_join: - return grid - if "WITHDRAWAL" not in r1_join and "DEPOSIT" not in r1_join: - return grid - if "DESCRIPTION" not in r1_join: - return grid - - # Guard: only treat as multiline header if row0 is mostly empty / generic title-ish cells. - non_empty_r0 = [x for x in r0 if x] - if len(non_empty_r0) > max(3, len(r0) // 2): - if not ("ACCOUNT HISTORY" in " ".join(r0).upper()): - return grid - generic_r0 = all( - (not x) or (x.upper() in ("AMOUNT", "ACCOUNT HISTORY", "DATE", "DESCRIPTION", "DEPOSIT", "WITHDRAWAL", "CARD #")) - for x in r0 - ) - if not generic_r0: - return grid - - # Use row1 as canonical header, keeping column count stable. - ncols = max(len(r0), len(r1)) - new_header = [] - for i in range(ncols): - v = (r1[i] if i < len(r1) else "").strip() - if not v and i < len(r0): - v = str(r0[i] or "").strip() - new_header.append(v) - - out = [new_header] - out.extend(grid[2:]) - return out - -def _normalize_checks_paid_table(grid): - if not grid or len(grid) < 2: - return grid - - ncols = len(grid[0]) - if ncols < 2: - return grid - - first_h = str(grid[0][0] or "").strip() - _CHECKS_HDR = re.compile(r"^(DATE\s+CHECK\s+#\s*)+$", re.IGNORECASE) - if not _CHECKS_HDR.match(first_h): - return grid - - rest_h = [str(c or "").strip().upper() for c in grid[0][1:]] - if not all(h in ("AMOUNT", "") for h in rest_h if h): - return grid - - n_groups = len(re.findall(r"\bDATE\b", first_h, re.IGNORECASE)) - if n_groups < 1: - return grid - - new_header = [] - for _ in range(n_groups): - new_header.extend(["DATE", "CHECK #", "AMOUNT"]) - - _DATE_RE = re.compile(r"^\d{1,2}/\d{2}$") - - new_rows = [new_header] - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - - fused_data = cells[0] - amounts = cells[1:] - - tokens = fused_data.split() - groups = [] - current = [] - for tok in tokens: - if _DATE_RE.match(tok) and current: - groups.append(current) - current = [tok] - else: - current.append(tok) - if current and current[0]: - groups.append(current) - - new_row = [""] * len(new_header) - for i, grp in enumerate(groups): - if i >= n_groups: - break - new_row[i * 3] = grp[0] - new_row[i * 3 + 1] = " ".join(grp[1:]).strip() - new_row[i * 3 + 2] = amounts[i] if i < len(amounts) else "" - - new_rows.append(new_row) - - return new_rows - -_JB_SECTION_HDR = re.compile( - r"^(Deposits?|Withdrawals?|Checks?(?:\s+Paid)?|Daily\s+Account\s+Balance)" - r"\s*(?:\(cont\.?\))?\s*$", - re.IGNORECASE, -) -_JB_COL_HDR = re.compile( - r"^Date\s+(Description|Number)\s+Amount\s*$" - r"|^Date\s+(?:Number\s+)?(?:Balance|Amount)\s*$", - re.IGNORECASE, -) -_JB_DATE_DD = re.compile(r"^\d{2}-\d{2}\s+") -_JB_TXN_ROW = re.compile(r"^(\d{2}-\d{2})\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$") -_JB_BAL_ROW = re.compile(r"^(\d{2}-\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s*$") - -def _jb_rows_to_html(header_cols, rows): - th = "".join("" + h + "" for h in header_cols) - parts = ["", "" + th + ""] - for row in rows: - td = "".join("" for h in header_cols) - parts.append("" + td + "") - parts.append("
" + str(row.get(h, "")) + "
") - return "\n".join(parts) - -def _jb_section_has_table(lines, start, end): - return any(" str: - lines = text.splitlines() - out = [] - i = 0 - - while i < len(lines): - line = lines[i].strip() - - if not _JB_SECTION_HDR.match(line): - out.append(lines[i]) - i += 1 - continue - - section_title = line - j = i + 1 - - while j < len(lines) and not lines[j].strip(): - j += 1 - - if j >= len(lines) or not _JB_COL_HDR.match(lines[j].strip()): - out.append(lines[i]) - i += 1 - continue - - col_hdr_line = lines[j].strip() - data_start = j + 1 - - first_data_idx = _find_first_data_row(lines, data_start) - - if first_data_idx is None or not _JB_DATE_DD.match(lines[first_data_idx].strip()): - out.append(lines[i]) - i += 1 - continue - - section_end = len(lines) - for k in range(data_start, len(lines)): - l = lines[k].strip() - if (l != section_title and _JB_SECTION_HDR.match(l) - or l.startswith("---page-separator") - or l.startswith("Page:")): - section_end = k - break - - if _jb_section_has_table(lines, i, section_end): - out.append(lines[i]) - i += 1 - continue - - parts_upper = col_hdr_line.upper().split() - if len(parts_upper) == 2 and parts_upper[1] == "BALANCE": - header_cols = ["Date", "Balance"] - elif "NUMBER" in parts_upper: - header_cols = ["Date", "Number", "Amount"] - else: - header_cols = ["Date", "Description", "Amount"] - - out.append(section_title) - rows = [] - current = None - k = data_start - - while k < section_end: - raw = lines[k] - row_line = raw.strip() - - if not row_line: - k += 1 - continue - - if (_JB_SECTION_HDR.match(row_line) - or row_line.startswith("---page-separator") - or row_line.startswith("Page:")): - break - - mb = _JB_BAL_ROW.match(row_line) - if mb and header_cols == ["Date", "Balance"]: - if current: - rows.append(current) - current = {"Date": mb.group(1), "Balance": mb.group(2)} - k += 1 - continue - - mt = _JB_TXN_ROW.match(row_line) - if mt: - if current: - rows.append(current) - desc_key = "Description" if "Description" in header_cols else "Number" - current = { - "Date": mt.group(1), - desc_key: mt.group(2).strip(), - "Amount": mt.group(3), - } - k += 1 - continue - - if current and row_line: - desc_key = "Description" if "Description" in header_cols else "Number" - if desc_key in current: - current[desc_key] += " " + row_line - k += 1 - continue - - k += 1 - - if current: - rows.append(current) - - if rows: - out.append(_jb_rows_to_html(header_cols, rows)) - - i = section_end - continue - - return "\n".join(out) - -# -------------------------- -# Navy Federal full-page text-layer parser (Fix NF-1) -# -------------------------- - -def _is_nfcu_document(pdf_path: str) -> bool: - try: - import pymupdf as fitz - doc = fitz.open(pdf_path) - pages_to_check = min(2, len(doc)) - text = "" - for i in range(pages_to_check): - text += "\n" + (doc[i].get_text() or "") - doc.close() - t = text.lower() - return ( - ("statement of account" in t) - and ("access no." in t) - and ("routing number" in t) - and ("navy federal" in t or "navyfederal.org" in t) - ) - except Exception: - return False - - -def _parse_nfcu_page(pdf_path: str, page_num: int) -> str: - try: - import pymupdf as fitz - - doc = fitz.open(pdf_path) - if page_num >= len(doc): - doc.close() - return "" - page = doc[page_num] - page_text = page.get_text() or "" - words = page.get_text("words") or [] - doc.close() - - if not page_text or not words: - return "" - - t = page_text.lower() - is_nfcu = ( - ("statement of account" in t) - and (("access no." in t) or ("routing number" in t) or ("navy federal" in t) or ("navyfederal.org" in t)) + with gr.Blocks(title="GLM-OCR (simple)") as demo: + gr.Markdown( + "# GLM-OCR (simple)\n" + "Upload a PDF or image. Header and footer bands are included; " + "body OCR is passed through with only light markdown cleanup." ) - if not is_nfcu: - return "" - - buckets = [] - for w in words: - if len(w) < 5: - continue - x0 = float(w[0]) - y0 = float(w[1]) - token = w[4].strip() - if not token: - continue - bi = None - for i, (yb, _) in enumerate(buckets): - if abs(yb - y0) <= 2.0: - bi = i - break - if bi is None: - buckets.append((y0, [(x0, token)])) - else: - buckets[bi][1].append((x0, token)) - - lines = [] - for y, arr in sorted(buckets, key=lambda x: x[0]): - toks = [tok for _, tok in sorted(arr, key=lambda x: x[0])] - xs = [x for x, _ in sorted(arr, key=lambda x: x[0])] - lines.append((y, xs, toks, " ".join(toks))) - - date_re = re.compile(r"^\d{2}-\d{2}$") - money_re = re.compile(r"^[\d,]+\.\d{2}-?$") - acct_re = re.compile(r"^(Business Checking|Mbr Business Savings)\s*-\s*(\d+)") - stop_markers = ( - "Items Paid", - "Average Daily Balance", - "2024 Year to Date", - "Disclosure", - "What to Do", - "Errors", - "Payments", - "SAVINGS DIVIDENDS", - "REMITTANCE RECEIVED", - "DEPOSIT VOUCHER", - "ACCOUNT NUMBER", - "MARK \"X\"", - "ADDRESS/ORDER", - "ITEMS ON REVERSE", - "CHANGE OF ADDRESS", - "PLEASE PRINT", - "RANK/RATE", - "PO BOX", - "MERRIFIELD", - "Questions about this Statement", + file_in = gr.File( + label="Upload PDF or image", + file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"], ) - - def parse_account_rows(start_idx): - rows = [] - i = start_idx - pending = None - while i < len(lines): - _, _, toks, text = lines[i] - if not text: - i += 1 - continue - - if acct_re.match(text) and i != start_idx: - break - if any(text.startswith(s) for s in stop_markers): - break - upper_text = text.upper() - if ( - "REMITTANCE RECEIVED" in upper_text - or "DEPOSIT VOUCHER" in upper_text - or "ACCOUNT NUMBER" in upper_text - or "ADDRESS/ORDER" in upper_text - or "ITEMS ON REVERSE" in upper_text - or "CHANGE OF ADDRESS" in upper_text - or "PLEASE PRINT" in upper_text - ): - break - if text in ("Checking", "Savings"): - break - if text.startswith("Date Transaction Detail"): - i += 1 - continue - - def flush_pending(): - nonlocal pending - if pending is not None: - rows.append(pending) - pending = None - - if toks and date_re.match(toks[0]): - flush_pending() - date = toks[0] - rest = toks[1:] - mvals = [tok for tok in rest if money_re.match(tok)] - desc_tokens = [tok for tok in rest if not money_re.match(tok)] - - amount = "" - balance = "" - if len(mvals) >= 2: - amount = mvals[-2] - balance = mvals[-1] - elif len(mvals) == 1: - balance = mvals[-1] - - pending = { - "date": date, - "desc": " ".join(desc_tokens).strip(), - "amount": amount, - "balance": balance, - } - else: - if pending is not None: - mvals = [tok for tok in toks if money_re.match(tok)] - desc_tokens = [tok for tok in toks if not money_re.match(tok)] - - upper = text.upper() - if pending["balance"] and ( - "REMITTANCE" in upper - or "DEPOSIT VOUCHER" in upper - or "ACCOUNT NUMBER" in upper - or "ADDRESS/ORDER" in upper - or "ITEMS ON REVERSE" in upper - or "PO BOX" in upper - ): - break - - if ( - ("BEGINNING BALANCE" in pending["desc"].upper() or "ENDING BALANCE" in pending["desc"].upper()) - and not mvals - and not (toks and date_re.match(toks[0])) - ): - break - if desc_tokens: - pending["desc"] = (pending["desc"] + " " + " ".join(desc_tokens)).strip() - if len(mvals) >= 2 and (not pending["amount"] or not pending["balance"]): - if not pending["amount"]: - pending["amount"] = mvals[-2] - if not pending["balance"]: - pending["balance"] = mvals[-1] - elif len(mvals) == 1 and not pending["balance"]: - pending["balance"] = mvals[-1] - i += 1 - - if pending is not None: - rows.append(pending) - cleaned = [r for r in rows if r["date"] and r["desc"] and (r["amount"] or r["balance"])] - return cleaned, i - - out = [] - i = 0 - while i < len(lines): - _, _, _, text = lines[i] - if not text: - i += 1 - continue - - m = acct_re.match(text) - if m: - acct_name = m.group(1) - acct_no = m.group(2) - title = f"{acct_name} - {acct_no}" - if i + 1 < len(lines) and "Continued" in lines[i + 1][3]: - title += " (Continued from previous page)" - out.append(f"{title}") - out.append("") - out.append("") - rows, i2 = parse_account_rows(i + 1) - for r in rows: - out.append( - f"" - ) - out.append("
DateTransaction DetailAmount($)Balance($)
{r['date']}{r['desc']}{r['amount']}{r['balance']}
") - i = i2 - continue - - if text in ("Checking", "Savings"): - out.append(f"

{text}

") - elif text.startswith("Items Paid"): - out.append("Items Paid") - i += 1 - - return "\n".join(out).strip() - - except Exception as e: - log.warning("_parse_nfcu_page failed (page %d): %s", page_num, e) - return "" - -def _extract_nfcu_summary_table(pdf_path: str, page_num: int) -> str: - try: - import pymupdf as fitz - - doc = fitz.open(pdf_path) - if page_num >= len(doc): - doc.close() - return "" - page = doc[page_num] - words = page.get_text("words") or [] - doc.close() - if not words: - return "" - - buckets = [] - for w in words: - if len(w) < 5: - continue - x0 = float(w[0]) - y0 = float(w[1]) - tok = str(w[4] or "").strip() - if not tok: - continue - bi = None - for i, (yb, _) in enumerate(buckets): - if abs(yb - y0) <= 2.0: - bi = i - break - if bi is None: - buckets.append((y0, [(x0, tok)])) - else: - buckets[bi][1].append((x0, tok)) - - lines = [] - for y, arr in sorted(buckets, key=lambda x: x[0]): - arr2 = sorted(arr, key=lambda x: x[0]) - toks = [t for _, t in arr2] - text = " ".join(toks).strip() - lines.append((y, toks, text)) - - anchor_idx = None - for i, (_, _, text) in enumerate(lines): - tl = text.lower() - if "summary of your deposit accounts" in tl or ("summary" in tl and "deposit accounts" in tl): - anchor_idx = i - break - if anchor_idx is None: - return "" - - money_re = re.compile(r"^\$?[\d,]+\.\d{2}-?$") - acct_no_re = re.compile(r"^\d{8,12}$") - stop_re = re.compile( - r"(business checking\s*-|mbr business savings\s*-|date\s+transaction\s+detail|items paid)", - re.IGNORECASE, - ) - - rows = [] - pending_account = "" - account_name_re = re.compile( - r"^(business checking|mbr business savings|checking|savings|money market|certificate).*$", - re.IGNORECASE, - ) - - for _, toks, text in lines[anchor_idx + 1:]: - if not text: - continue - if stop_re.search(text): - break - if not toks: - continue - t0 = toks[0].lower() - tl = text.lower() - - if ( - "previous" in tl - or "deposits" in tl - or "withdrawals" in tl - or "ending" in tl - or "dividends" in tl - ) and not any(money_re.match(t) for t in toks): - continue - - if account_name_re.match(text.strip()) and not any(money_re.match(t) for t in toks): - pending_account = text.strip() - continue - - mvals = [t.replace("$", "") for t in toks if money_re.match(t)] - acct_tokens = [t for t in toks if acct_no_re.match(t)] - - if t0 == "totals" and len(mvals) >= 5: - rows.append(["Totals", mvals[0], mvals[1], mvals[2], mvals[3], mvals[4]]) - continue - - if acct_tokens and len(mvals) >= 5: - acct = acct_tokens[0] - acct_label = (pending_account + " - " + acct).strip(" -") if pending_account else acct - rows.append([acct_label, mvals[0], mvals[1], mvals[2], mvals[3], mvals[4]]) - pending_account = "" - continue - - if not rows: - return "" - - hdr = [ - "Account", - "Previous Balance", - "Deposits/Credits", - "Withdrawals/Debits", - "Ending Balance", - "YTD Dividends", - ] - return _grid_to_html([hdr] + rows) - except Exception: - return "" - -def _replace_nfcu_summary_table(page_md: str, summary_table_html: str) -> str: - if not page_md or not summary_table_html: - return page_md - table_pattern = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) - lower = page_md.lower() - anchor = lower.find("summary of your deposit accounts") - - if anchor >= 0: - for m in table_pattern.finditer(page_md): - if m.start() > anchor: - old_table = m.group(0) - return page_md.replace(old_table, summary_table_html, 1) - - for m in table_pattern.finditer(page_md): - tbl = m.group(0) - p = TableGridParser() - p.feed(tbl) - g = _build_grid(p.rows) - if not g: - continue - h = " ".join(str(c or "").strip().lower() for c in g[0]) - if ( - "previous" in h - and "deposits" in h - and "withdrawals" in h - and "ending" in h - and "dividends" in h - ): - return page_md.replace(tbl, summary_table_html, 1) - - return page_md - -def _split_fused_date_transaction_header(grid): - if not grid or len(grid) < 2: - return grid - ncols = max(len(r) for r in grid if isinstance(r, list)) - if ncols < 3: - return grid - - header_row_idx = None - fused_col_idx = None - for ri in range(min(4, len(grid))): - r = [str(c or "").strip() for c in (grid[ri] + [""] * (ncols - len(grid[ri])))] - for ci, c in enumerate(r): - cc = re.sub(r"\s+", " ", c).strip().lower() - if cc == "date transaction detail": - row_text = " ".join(re.sub(r"\s+", " ", x).strip().lower() for x in r) - if "amount" in row_text and "balance" in row_text: - header_row_idx = ri - fused_col_idx = ci - break - if header_row_idx is not None: - break - if header_row_idx is None or fused_col_idx is None: - return grid - - date_tok = re.compile(r"^\d{1,2}(?:[-/])\d{2}(?:[-/]\d{2,4})?$") - - new_grid = [] - for ri, row in enumerate(grid): - cells = [str(c or "").strip() for c in (row + [""] * (ncols - len(row)))] - first = cells[fused_col_idx] - dval = "" - tval = first - if first: - parts = first.split() - if parts and date_tok.match(parts[0]): - dval = parts[0] - tval = " ".join(parts[1:]).strip() - if ri == header_row_idx: - split_row = ( - cells[:fused_col_idx] - + ["Date", "Transaction Detail"] - + cells[fused_col_idx + 1:] - ) - elif ri < header_row_idx: - split_row = cells[:fused_col_idx] + [cells[fused_col_idx], ""] + cells[fused_col_idx + 1:] - else: - split_row = cells[:fused_col_idx] + [dval, tval] + cells[fused_col_idx + 1:] - new_grid.append(split_row) - return new_grid - -def _split_combined_summary_daily_balance_grid(grid): - if not grid or len(grid) < 4: - return None - - def _norm(s): - return re.sub(r"\s+", " ", str(s or "").strip().lower()) - - daily_idx = None - for i, row in enumerate(grid): - row_text = " ".join(_norm(c) for c in row if str(c or "").strip()) - if "daily balance" in row_text: - daily_idx = i - break - if daily_idx is None or daily_idx <= 1 or daily_idx >= len(grid) - 2: - return None - - date_re = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$") - money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - daily_rows = [] - for row in grid[daily_idx + 1:]: - tokens = [] - for cell in row: - ct = str(cell or "").strip() - if not ct: - continue - tokens.extend(re.split(r"\s+", ct)) - dates = [t for t in tokens if date_re.match(t)] - amts = [t for t in tokens if money_re.match(t)] - if not dates or not amts: - continue - for d, a in zip(dates, amts): - daily_rows.append([d, a]) - - if len(daily_rows) < 2: - return None - - summary = [r for r in grid[:daily_idx] if any(str(c or "").strip() for c in r)] - if len(summary) < 2: - return None - - daily = [["Date", "Ledger Balance"]] + daily_rows - return [summary, daily] - -def _normalize_double_desc_amount_header_table(grid): - if not grid or len(grid) < 2: - return grid - - def _hdr_tight(s): - return re.sub(r"\s+", "", str(s or "").strip().lower()) - - expected5_t = ["date", "description", "amount", "description", "amount"] - expected4_t = ["date", "description", "amount", "description"] - - def _row_is_ucb_header(cells): - if len(cells) == 5: - return [_hdr_tight(c) for c in cells] == expected5_t - if len(cells) == 4: - return [_hdr_tight(c) for c in cells] == expected4_t - return False - - hdr_idx = None - ncols = 0 - for i in range(min(5, len(grid))): - raw_try = [str(c or "").strip() for c in grid[i]] - if _row_is_ucb_header(raw_try): - hdr_idx = i - ncols = len(raw_try) - break - if hdr_idx is None: - return grid - - date_only_re = re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$") - date_prefix_re = re.compile(r"^(\d{1,2}/\d{1,2}/\d{2,4})\s*(.*)$") - money_cell_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - def _is_money(s): - if not s or not str(s).strip(): - return False - return bool(money_cell_re.match(str(s).strip())) - - def _extract_money(s): - if not s: - return "" - m = re.search(r"(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?)", str(s)) - return m.group(1).strip() if m else "" - - out = [["Date", "Description", "Amount"]] - - for row in grid[hdr_idx + 1 :]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - c0, c1, c2 = cells[0], cells[1] if len(cells) > 1 else "", cells[2] if len(cells) > 2 else "" - c3 = cells[3] if len(cells) > 3 else "" - c4 = cells[4] if len(cells) > 4 else "" - - if not any(cells[:ncols]): - continue - - mpre = date_prefix_re.match(c0) - if mpre and _is_money(c1): - date_s = mpre.group(1).strip() - low = (c0 + " " + (mpre.group(2) or "")).lower() - if "beginning balance" in low: - desc = "Beginning Balance" - elif "ending balance" in low: - desc = "Ending Balance" - else: - desc = (mpre.group(2) or "").strip() or "Transaction" - out.append([date_s, desc, c1]) - continue - - if date_only_re.match(c0.strip()): - date_s = c0.strip() - desc = c1 - amt = c2 - if desc and "beginning balance" in desc.lower(): - mx = _extract_money(desc) - if mx and (not amt or amt in ("$0.00", "0.00", "$0", ".00", "$.00")): - amt = mx - desc = re.sub( - r"(?i)beginning\s+balance\s*\$?[\d,]+\.?\d*\s*", - "Beginning Balance ", - desc, - ) - desc = re.sub(r"(?i)\s*average\s+ledger\s+balance\s*", " ", desc).strip() - if not desc or desc == "Beginning Balance": - desc = "Beginning Balance" - out.append([date_s, desc, amt]) - continue - - if not date_prefix_re.match(c0) and not date_only_re.match(c0.strip()) and _is_money(c1): - out.append(["", c0, c1]) - continue - - out.append([c0, c1, c2]) - - return out if len(out) >= 2 else grid - - -def _normalize_ucb_three_col_beginning_balance_fusion(grid): - if not grid or len(grid) < 2: - return grid - - def _tight(s): - return re.sub(r"\s+", "", str(s or "").strip().lower()) - - h0 = [str(c or "").strip() for c in grid[0]] - if len(h0) < 3: - return grid - if _tight(h0[0]) != "date" or _tight(h0[1]) != "description" or _tight(h0[2]) != "amount": - return grid - - date_only_re = re.compile(r"^\d{1,2}/\d{1,2}/\d{2,4}$") - - def _extract_money(s): - if not s: - return "" - m = re.search(r"(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?)", str(s)) - return m.group(1).strip() if m else "" - - zeroish = {"", "$0.00", "0.00", "$0", ".00", "$.00", "0"} - - out = [list(grid[0])] - changed = False - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - c0, c1, c2 = cells[0], cells[1], cells[2] - if ( - date_only_re.match(c0.strip()) - and c1 - and "beginning balance" in c1.lower() - ): - mx = _extract_money(c1) - c2n = c2.strip() if c2 else "" - if mx and (not c2n or c2n in zeroish): - out.append([c0.strip(), "Beginning Balance", mx]) - changed = True - continue - out.append([c0, c1, c2]) - - return out if changed else grid - - -def _fix_three_col_date_desc_amount_left_shift(grid): - if not grid or len(grid) < 2: - return grid - - def _tight(s): - return re.sub(r"\s+", "", str(s or "").strip().lower()) - - h0 = [str(c or "").strip() for c in grid[0]] - if len(h0) < 3: - return grid - if _tight(h0[0]) != "date" or _tight(h0[1]) != "description" or _tight(h0[2]) != "amount": - return grid - - date_then_desc = re.compile( - r"^(\d{1,2}/\d{1,2}/\d{2,4})\s+(.+)$" - ) - money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - out = [list(grid[0])] - changed = False - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - c0, c1, c2 = cells[0], cells[1], cells[2] - if not c0: - out.append([c0, c1, c2]) - continue - m = date_then_desc.match(c0) - if not m: - out.append([c0, c1, c2]) - continue - desc_part = (m.group(2) or "").strip() - if not desc_part: - out.append([c0, c1, c2]) - continue - if not (c1 and money_re.match(c1.strip())): - out.append([c0, c1, c2]) - continue - if c2 and str(c2).strip(): - out.append([c0, c1, c2]) - continue - out.append([m.group(1).strip(), desc_part, c1.strip()]) - changed = True - - return out if changed else grid - - -# CO-OP account banner: digits + optional CONT (OCR often truncates "CONT") -_BUSINESS_COOP_BANNER_FIRST_CELL_RE = re.compile( - r"^BUSINESS\s*-\s*\d+(?:\s*CONT)?\s*$", - re.IGNORECASE, -) - - -_BUSINESS_COOP_BANNER_REJECT_RE = re.compile( - r"\b(checking|savings|inspire|advantage|mbr|premier|relationship)\b", - re.IGNORECASE, -) - - -def _first_cell_is_business_coop_banner(s: str) -> bool: - """ - True only for CO-OP-style account banner cells (BUSINESS - [CONT]). - Reject product lines like 'BUSINESS INSPIRE CHECKING …', 'Business Checking-…', - or 'BUSINESS ADVANTAGE' so wide summary / Navy / Amegy tables are not rewritten. - """ - t = str(s or "").strip() - if not t or _BUSINESS_COOP_BANNER_REJECT_RE.search(t): - return False - return bool(_BUSINESS_COOP_BANNER_FIRST_CELL_RE.match(t)) - - -def _fix_business_coop_cont_three_col_register_grid(grid): - """ - Fix SE-CONT: CO-OP network continuation (BUSINESS-…CONT | Amount | Amount) where col1 is - MM/DD + description, col2 is transaction amount, col3 is running balance. Downstream - extraction treats the two money columns like Amount/Balance in a different column order, - inflating debits/credits. Expand to the same 4-column register as the first page. - """ - if not grid or len(grid) < 3: - return grid - - def _tight_cell(s: str) -> str: - return re.sub(r"\s+", "", str(s or "").strip().lower()) - - hdr_idx = None - scan = min(5, len(grid)) - for ri in range(scan): - cells = [str(c or "").strip() for c in grid[ri]] - while len(cells) > 3 and cells[-1] == "": - cells.pop() - while len(cells) < 3: - cells.append("") - if len(cells) != 3: - continue - if _first_cell_is_business_coop_banner(cells[0]) and _tight_cell( - cells[1] - ) == "amount" and _tight_cell(cells[2]) == "amount": - hdr_idx = ri - break - if hdr_idx is None: - return grid - - date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL) - money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - def _is_date_txn_desc_subheader(cells: List[str]) -> bool: - c0 = str(cells[0] or "").strip().lower() - return ( - "date" in c0 - and "transaction" in c0 - and "description" in c0 - and len(c0) < 80 - ) - - data_start = hdr_idx + 1 - if data_start < len(grid): - r1 = [str(c or "").strip() for c in grid[data_start]] - while len(r1) < 3: - r1.append("") - if _is_date_txn_desc_subheader(r1): - data_start += 1 - - new_grid = [ - ["Date", "Transaction Description", "Transaction Amount", "Balance"], - ] - changed = False - for row in grid[data_start:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - c0, c1, c2 = cells[0], cells[1], cells[2] - if not c0: - continue - m = date_desc_re.match(c0.strip()) - if not m: - continue - amt = (c1 or "").strip() - bal = (c2 or "").strip() - if not amt or not bal: - continue - if not money_re.match(amt) or not money_re.match(bal): - continue - new_grid.append( - [ - m.group(1).strip(), - (m.group(2) or "").strip(), - amt, - bal, - ] - ) - changed = True - - if not changed or len(new_grid) < 2: - return grid - return new_grid - - -def _fix_business_coop_cont_four_col_register_grid(grid): - """ - Fix SE-CONT (4-col): Same CO-OP continuation banner as 3-col, but OCR uses four columns: - BUSINESS-…CONT | (blank) | Amount | Amount, then subheader, then rows where col0 is - MM/DD + description start, col1 is description tail (e.g. SEATTLE), col2 amount, col3 balance. - Without merging, downstream maps columns incorrectly. Output canonical 4-column register. - """ - if not grid or len(grid) < 3: - return grid - - ncols = max(len(r) for r in grid if isinstance(r, list)) - if ncols < 4: - return grid - - def _tight_cell(s: str) -> str: - return re.sub(r"\s+", "", str(s or "").strip().lower()) - - hdr_idx = None - for ri in range(min(6, len(grid))): - cells = [str(c or "").strip() for c in grid[ri]] - while len(cells) > 4 and cells[-1] == "": - cells.pop() - if len(cells) != 4: - continue - if not _first_cell_is_business_coop_banner(cells[0]): - continue - if _tight_cell(cells[2]) != "amount" or _tight_cell(cells[3]) != "amount": - continue - hdr_idx = ri - break - if hdr_idx is None: - return grid - - date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL) - money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - def _skip_subheader(cells: List[str]) -> bool: - c0 = str(cells[0] or "").strip() - cl = c0.lower() - if "date" not in cl or "transaction" not in cl or "description" not in cl: - return False - return not bool(date_desc_re.match(c0.strip())) - - data_start = hdr_idx + 1 - while data_start < len(grid): - cells = [str(c or "").strip() for c in grid[data_start]] - while len(cells) < ncols: - cells.append("") - if _skip_subheader(cells): - data_start += 1 - continue - break - - new_grid = [ - ["Date", "Transaction Description", "Transaction Amount", "Balance"], - ] - changed = False - for row in grid[data_start:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < ncols: - cells.append("") - c0 = cells[0] if cells else "" - c1 = cells[1] if len(cells) > 1 else "" - c2 = cells[2] if len(cells) > 2 else "" - c3 = cells[3] if len(cells) > 3 else "" - if not c0: - continue - m = date_desc_re.match(c0.strip()) - if not m: - continue - amt = (c2 or "").strip() - bal = (c3 or "").strip() - if not amt or not bal: - continue - if not money_re.match(amt) or not money_re.match(bal): - continue - desc = ((m.group(2) or "").strip() + " " + (c1 or "").strip()).strip() - new_grid.append([m.group(1).strip(), desc, amt, bal]) - changed = True - - if not changed or len(new_grid) < 2: - return grid - return new_grid - - -def _normalize_fused_date_posted_amount_table(grid): - if not grid or len(grid) < 2: - return grid - - ncols = max(len(r) for r in grid if isinstance(r, list)) - if ncols < 2: - return grid - - def _row_cells(r): - return [str(c or "").strip() for c in (r + [""] * (ncols - len(r)))] - - hdr_idx = None - for ri in range(min(6, len(grid))): - cells = _row_cells(grid[ri]) - row_text = " ".join(cells).lower() - if ( - "date posted transaction description" in row_text - and "amount" in row_text - ): - hdr_idx = ri - break - if hdr_idx is None: - return grid - - date_re = re.compile(r"^\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?$") - out = [] - - for ri, row in enumerate(grid): - cells = _row_cells(row) - if ri == hdr_idx: - out.append(["Date", "Transaction Description", "Amount"]) - continue - if ri < hdr_idx: - joined = " ".join(c for c in cells if c).strip() - out.append(["", joined, ""]) - continue - - c1 = cells[0].strip() - c2 = cells[1].strip() if len(cells) > 1 else "" - - lower_c1 = c1.lower() - if ( - lower_c1.startswith("date posted transaction description") - or lower_c1 in {"ach additions", "ach deductions", "other additions", "other deductions", "service charges and fees"} - ): - continue - - date = "" - desc = c1 - if c1: - parts = c1.split() - if parts and date_re.match(parts[0]): - date = parts[0] - desc = " ".join(parts[1:]).strip() - - amount = c2 - if not amount: - money_cells = [] - for x in cells[1:]: - t = str(x or "").strip() - if re.match(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$", t): - money_cells.append(t) - if money_cells: - amount = money_cells[-1] - - if not amount and desc: - m = re.search(r"(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?)\s*$", desc) - if m: - amount = m.group(1) - desc = desc[:m.start()].strip() - - if not (date or desc or amount): - continue - out.append([date, desc, amount]) - - if len(out) < 2: - return grid - return out - - -_TD_CONTINUED_BLOB_RE = re.compile( - r"transactions\s+by\s+date.*continued|continued.*transactions\s+by\s+date", - re.IGNORECASE | re.DOTALL, -) - -# GLM often omits the continued banner inside a standalone ; match TD register body text. -_TD_REGISTER_BODY_SIGNAL_RE = re.compile( - r"TD\s+ZELLE|\bZELLE\b.*\b(?:RECEIVED|SENT)\b|LENDINGCLUB|MM\s*&\s*(?:amp;)?\s*CB|" - r"MAINTENANCE\s+FEE|OD\s+GRACE\s+FEE\s+REFUND|OVERDRAFT\s+PD", - re.IGNORECASE, -) - - -def _is_td_four_col_ddcb_header(cells: List[str]) -> bool: - """TD continuation: one fused DATE DESCRIPTION cell + DEBIT + CREDIT + BALANCE.""" - if len(cells) < 4: - return False - a = re.sub(r"\s+", " ", str(cells[0] or "").strip().lower()) - b = re.sub(r"\s+", " ", str(cells[1] or "").strip().lower()) - c = re.sub(r"\s+", " ", str(cells[2] or "").strip().lower()) - d = re.sub(r"\s+", " ", str(cells[3] or "").strip().lower()) - fused = "date" in a and "description" in a - deb = b == "debit" or b.startswith("debit") - cre = "credit" in c and "debit" not in c - bal = "balance" in d - return fused and deb and cre and bal - - -def _split_td_continuation_date_description_grid(grid): - """ - Fix TD-1: TD Convenience Checking continuation pages use four physical columns; split col0 - into Date + Description so json_data_extractor's five-way column map aligns with row cells. - """ - if not grid or len(grid) < 3: - return grid - - blob = " ".join( - " ".join(str(c or "") for c in row) for row in grid[: min(30, len(grid))] - ) - if not _TD_CONTINUED_BLOB_RE.search(blob): - return grid - - hdr_idx = None - for ri in range(min(20, len(grid))): - raw = [str(c or "").strip() for c in grid[ri]] - while len(raw) < 4: - raw.append("") - if len(raw) >= 4 and _is_td_four_col_ddcb_header(raw[:4]): - hdr_idx = ri - break - if hdr_idx is None: - return grid - - date_led_re = re.compile(r"^(\d{1,2}/\d{1,2}(?:/\d{2,4})?)\s+(.*)$", re.DOTALL) - new_grid: List[List[str]] = [] - for ri, row in enumerate(grid): - cells = [str(c or "").strip() for c in row] - if ri < hdr_idx: - new_grid.append(cells) - continue - if ri == hdr_idx: - c = cells[:4] + [""] * max(0, 4 - len(cells)) - new_grid.append( - [ - "DATE", - "DESCRIPTION", - c[1] or "DEBIT", - c[2] or "CREDIT", - c[3] or "BALANCE", - ] - ) - continue - while len(cells) < 4: - cells.append("") - if len(cells) == 4: - m = date_led_re.match(cells[0].strip()) - if m: - new_grid.append( - [ - m.group(1).strip(), - (m.group(2) or "").strip(), - cells[1], - cells[2], - cells[3], - ] - ) - else: - new_grid.append([cells[0], "", cells[1], cells[2], cells[3]]) - elif len(cells) >= 5: - new_grid.append(cells) - else: - while len(cells) < 5: - cells.append("") - new_grid.append(cells[:5]) - - return new_grid - - -def _is_td_three_col_register_header(cells: List[str]) -> bool: - """OCR variant: Date | Transaction Description | Amount (Amount column is running balance).""" - if len(cells) < 3: - return False - a = re.sub(r"\s+", " ", str(cells[0] or "").strip().lower()) - b = re.sub(r"\s+", " ", str(cells[1] or "").strip().lower()) - c = re.sub(r"\s+", " ", str(cells[2] or "").strip().lower()) - if a != "date": - return False - if "transaction" not in b or "description" not in b: - return False - if c not in ("amount", "balance") and "amount" not in c and "balance" not in c: - return False - return True - - -def _td_trailing_amount_from_description(desc: str) -> Tuple[str, str]: - """Return (description_without_trailing_amount, amount_str or '').""" - if not desc or not str(desc).strip(): - return "", "" - s = str(desc).strip() - m = re.search(r"(?:,\s*|\s)(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$", s) - if not m: - return s, "" - amt = m.group(1).strip() - left = s[: m.start()].strip().rstrip(",") - return left, amt - - -def _td_route_debit_credit(desc_raw: str, amt_str: str) -> Tuple[str, str]: - """Return (debit_cell, credit_cell) with positive amount string in one side.""" - if not amt_str: - return "", "" - u = str(desc_raw or "").upper() - if ( - "ACH DEBIT" in u - or "OVERDRAFT" in u - or "ZELLE SENT" in u - or "MAINTENANCE FEE" in u - or "ATM DB" in u - or "ATM DEBIT" in u - or "ELECTRONIC PMT" in u - or "PMT-WEB" in u - ): - return amt_str, "" - if ( - "ZELLE RECEIVED" in u - or "OD GRACE FEE REFUND" in u - or u.startswith("CREDIT,") - or u.startswith("DEPOSIT") - ): - return "", amt_str - if "DEBIT" in u and "CREDIT," not in u[:12]: - return amt_str, "" - return "", amt_str - - -def _expand_td_continuation_three_col_register_grid(grid): - """ - Fix TD-1b: GLM emits Date|Transaction Description|Amount where col3 is balance; expand to - DATE|DESCRIPTION|DEBIT|CREDIT|BALANCE for json_data_extractor. - """ - if not grid or len(grid) < 2: - return grid - - blob = " ".join( - " ".join(str(c or "") for c in row) for row in grid[: min(40, len(grid))] - ) - if not ( - _TD_CONTINUED_BLOB_RE.search(blob) - or _TD_REGISTER_BODY_SIGNAL_RE.search(blob) - ): - return grid - - hdr_idx = None - for ri in range(min(15, len(grid))): - raw = [str(c or "").strip() for c in grid[ri]] - while len(raw) < 3: - raw.append("") - if _is_td_three_col_register_header(raw[:3]): - hdr_idx = ri - break - if hdr_idx is None: - return grid - - date_re = re.compile(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$") - new_grid: List[List[str]] = [] - for ri, row in enumerate(grid): - cells = [str(c or "").strip() for c in row] - if ri < hdr_idx: - new_grid.append(cells) - continue - if ri == hdr_idx: - new_grid.append(["DATE", "DESCRIPTION", "DEBIT", "CREDIT", "BALANCE"]) - continue - while len(cells) < 3: - cells.append("") - if len(cells) != 3: - new_grid.append(cells) - continue - d0, d1, bal = cells[0], cells[1], cells[2] - if not date_re.match(d0): - new_grid.append([d0, d1, "", "", bal]) - continue - desc_only, amt_s = _td_trailing_amount_from_description(d1) - db, cr = _td_route_debit_credit(d1, amt_s) - new_grid.append([d0, desc_only, db, cr, bal]) - - return new_grid - - -def _parse_summary_table_money(cell: str) -> Optional[float]: - """Parse a currency cell from a statement summary row; None if not numeric.""" - if cell is None: - return None - t = str(cell).strip().replace(",", "").replace("$", "").replace(" ", "") - if not t or t in ("-", "—", "–"): - return None - t = t.replace("(", "-").replace(")", "") - try: - return float(t) - except ValueError: - return None - - -def _format_summary_table_money(value: float) -> str: - sign = "-" if value < 0 else "" - v = abs(value) - s = f"{v:,.2f}" - return sign + s - - -def _drop_phantom_micro_account_rows_in_multi_account_summary(grid): - """ - Remove OCR phantom sub-account rows in credit-union / multi-account deposit summary tables - (Acct | Beginning | Deposits | Withdrawals | Ending | …) when one row is dust and another - is statement-scale; recompute TOTAL from remaining account rows. - """ - if not grid or len(grid) < 4: - return grid - - header = [str(c or "").strip() for c in grid[0]] - ncols = len(header) - if ncols < 5: - return grid - - header_join_u = " ".join(h.upper() for h in header) - if "DEPOSIT" not in header_join_u or "WITHDRAW" not in header_join_u: - return grid - if "BEGINNING" not in header_join_u and "PREVIOUS" not in header_join_u: - return grid - has_ending = "ENDING" in header_join_u or ( - "NEW" in header_join_u and "BALANCE" in header_join_u - ) - if not has_ending: - return grid - - def _col_match(pred): - for i, h in enumerate(header): - hu = h.upper() - if pred(hu): - return i - return None - - i_beg = _col_match(lambda u: "BEGINNING" in u or ("PREVIOUS" in u and "BALANCE" in u)) - i_dep = _col_match(lambda u: "DEPOSIT" in u and "WITHDRAW" not in u) - i_wd = _col_match(lambda u: "WITHDRAW" in u) - i_end = _col_match( - lambda u: "ENDING" in u - or ("NEW" in u and "BALANCE" in u) - or u.startswith("CLOSING") - ) - if None in (i_beg, i_dep, i_wd, i_end): - return grid - if len({i_beg, i_dep, i_wd, i_end}) != 4: - return grid - - money_idxs = (i_beg, i_dep, i_wd, i_end) - - # Dust vs statement scale (tuned for OCR rows like -1 / 2 / 0 / 1 next to 18k withdrawals) - _DUST_MAX = 10.0 - _DUST_SUM = 15.0 - _REAL_MIN = 3000.0 - - def _pad_row(row): - r = [str(c or "").strip() for c in row] - while len(r) < ncols: - r.append("") - return r[:ncols] - - account_rows = [] # (grid_index, row_padded, max_abs, sum_abs, vals tuple) - total_idx: Optional[int] = None - - for ri in range(1, len(grid)): - row = _pad_row(grid[ri]) - fc = row[0].strip().upper() - if fc == "TOTAL" or fc.startswith("TOTAL "): - total_idx = ri - continue - vals = tuple( - _parse_summary_table_money(row[j]) if j < len(row) else None - for j in money_idxs - ) - if all(v is None for v in vals): - continue - abs_vals = [abs(v) for v in vals if v is not None] - max_abs = max(abs_vals) if abs_vals else 0.0 - sum_abs = sum(abs(v) for v in vals if v is not None) - account_rows.append((ri, row, max_abs, sum_abs, vals)) - - if len(account_rows) < 2: - return grid - - global_max = max(t[2] for t in account_rows) - if global_max < _REAL_MIN: - return grid - - phantom_ri = set() - for ri, row, max_abs, sum_abs, vals in account_rows: - if max_abs <= _DUST_MAX and sum_abs <= _DUST_SUM: - phantom_ri.add(ri) - - if not phantom_ri: - return grid - - kept_accounts = [ - (ri, row, vals) - for ri, row, max_abs, sum_abs, vals in account_rows - if ri not in phantom_ri - ] - if not kept_accounts: - return grid - - # Sum the four money columns across kept account rows - sums = [0.0, 0.0, 0.0, 0.0] - for _ri, _row, vals in kept_accounts: - for k in range(4): - if vals[k] is not None: - sums[k] += vals[k] - - new_grid: List[List[str]] = [list(header)] - for ri in range(1, len(grid)): - if ri in phantom_ri: - continue - if ri == total_idx: - continue - new_grid.append(_pad_row(grid[ri])) - - if total_idx is not None: - total_row = [""] * ncols - total_row[0] = "TOTAL" - for k, j in enumerate(money_idxs): - total_row[j] = _format_summary_table_money(sums[k]) - old_total = _pad_row(grid[total_idx]) - for j in range(ncols): - if j in (0,) + money_idxs: - continue - hj = header[j].upper() if j < len(header) else "" - if "DIVIDEND" in hj or "YTD" in hj: - total_row[j] = old_total[j] if old_total[j] else "0.00" - elif old_total[j]: - total_row[j] = old_total[j] - new_grid.append(total_row) - - return new_grid - - -def normalize_html_tables(text: str) -> str: - if not text or "]*>.*?
", re.DOTALL | re.IGNORECASE) - out = [] - last = 0 - - for m in pattern.finditer(text): - out.append(text[last : m.start()]) - table_html = m.group(0) - try: - p = TableGridParser() - p.feed(table_html) - grid = _build_grid(p.rows) - - if _is_junk_table(grid): - out.append("") - last = m.end() - continue - - grid = _normalize_double_desc_amount_header_table(grid) - grid = _normalize_ucb_three_col_beginning_balance_fusion(grid) - grid = _fix_three_col_date_desc_amount_left_shift(grid) - grid = _fix_business_coop_cont_three_col_register_grid(grid) - grid = _fix_business_coop_cont_four_col_register_grid(grid) - - grid = _drop_truly_empty_columns(grid) - grid = _merge_blank_header_text_columns(grid) - grid = _drop_phantom_micro_account_rows_in_multi_account_summary(grid) - _seq_linear = _split_wide_checks_sequenced_grid(grid) - if _seq_linear is not None: - grid = _seq_linear - - grid = _fill_headless_trailing_amount_headers(grid) - grid = _normalize_date_desc_money_table_alignment(grid) - - grid = _reconstruct_separator_rows(grid) - - grid, recovered_row = _clean_header_artifacts(grid) - if recovered_row is not None: - grid.insert(1, recovered_row) - - grid = _promote_misplaced_header_row(grid) - - grid = _split_fused_date_transaction_header(grid) - - grid = _split_td_continuation_date_description_grid(grid) - grid = _expand_td_continuation_three_col_register_grid(grid) - - grid = _normalize_fused_date_posted_amount_table(grid) - - grid = _split_fused_multicolumn_header(grid) - grid = _normalize_multiline_register_header(grid) - - grid = _normalize_daily_balance_table(grid) - - grid = _normalize_checks_paid_table(grid) - - grid = _merge_split_rows(grid) - - grid = _extract_fused_desc_amount(grid) - - grid = _repair_da3_garbled_amount_cells(grid) - - grid = _fix_fused_keyvalue_rows(grid) - grid = _dedupe_duplicate_rows_in_da3_table(grid) - grid = _dedupe_consecutive_identical_da4_register_rows(grid) - split_tables = _split_combined_summary_daily_balance_grid(grid) - if split_tables: - out.append("\n\n".join(_grid_to_html(g) for g in split_tables if g)) - else: - out.append(_grid_to_html(grid) if grid else table_html) - except Exception: - out.append(table_html) - last = m.end() - - out.append(text[last:]) - return "".join(out) - - -def _normalize_packed_daily_balance_and_checks_summary_tables(text: str) -> str: - """ - Normalize packed statement summary tables often seen in First Horizon OCR output: - - DAILY BALANCE SUMMARY with grouped date cells (e.g. "01/02 01/03 01/04 01/05") - - CHECKS PAID SUMMARY with grouped "DATE CHECK #" triplets + parallel amount columns - """ - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - out = [] - last = 0 - - _DATE_RE = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$") - - for m in pattern.finditer(text): - out.append(text[last:m.start()]) - tbl = m.group(0) - try: - p = TableGridParser() - p.feed(tbl) - g = _build_grid(p.rows) - if not g or len(g) < 3: - out.append(tbl) - last = m.end() - continue - - h0 = " ".join(str(c or "").strip().upper() for c in g[0]) - - # DAILY BALANCE SUMMARY: unpack grouped dates into one row per date. - if "DAILY BALANCE SUMMARY" in h0: - rows = [["Date", "Balance"]] - for row in g[1:]: - vals = [str(c or "").strip() for c in row] - if not any(vals): - continue - if any("BALANCE" == v.upper() for v in vals): - continue - date_tokens = [] - for cell in vals: - for tok in re.split(r"[\s\r\n]+", cell): - t = tok.strip() - if _DATE_RE.match(t): - date_tokens.append(t) - amt_cells = [v for v in vals if _DA3_STRICT_AMOUNT.match(v)] - for d, a in zip(date_tokens, amt_cells): - rows.append([d, a]) - if len(rows) >= 3: - out.append(_grid_to_html(rows)) - last = m.end() - continue - - # CHECKS PAID SUMMARY: unpack grouped DATE/CHECK# with parallel amount columns. - if "CHECKS PAID SUMMARY" in h0: - rows = [["Date", "Check #", "Amount"]] - for row in g[1:]: - vals = [str(c or "").strip() for c in row] - if not any(vals): - continue - if "DATE CHECK #" in " ".join(vals).upper(): - continue - left = vals[0] if vals else "" - amounts = [v for v in vals[1:] if _DA3_STRICT_AMOUNT.match(v)] - # tokens like: 01/24 4701 01/18 4704 * 01/18 916831 * - tokens = [t for t in re.split(r"\s+", left) if t] - pairs = [] - i = 0 - while i < len(tokens): - if _DATE_RE.match(tokens[i]) and i + 1 < len(tokens): - chk = tokens[i + 1] - if chk != "*": - pairs.append((tokens[i], chk)) - i += 2 - continue - i += 1 - for (d, chk), amt in zip(pairs, amounts): - rows.append([d, chk, amt]) - if len(rows) >= 2: - out.append(_grid_to_html(rows)) - last = m.end() - continue - - out.append(tbl) - except Exception: - out.append(tbl) - last = m.end() - - out.append(text[last:]) - return "".join(out) - - -def _transaction_row_dedupe_key(cells): - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s, desc_s, amt_s = parts[0], parts[1], parts[2] - date_s = re.sub(r"\s+", "", date_s) - amt_s = re.sub(r"[\s$,]", "", amt_s).lower().replace("−", "-") - desc_s = html.unescape(desc_s) - desc_s = desc_s.replace("`", "'").replace("'", "'") - desc_s = re.sub(r"\s+", "", desc_s.lower()) - return (date_s, desc_s, amt_s) - - -def _da3_loose_key(cells): - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s, desc_s, amt_s = parts[0], parts[1], parts[2] - if not _DA3_LEADING_DATE.match(date_s): - return None - comb = f"{date_s} {desc_s}".lower() - if re.search(r"\btotal\b", comb) and len(desc_s) > 25: - return None - if not _DA3_STRICT_AMOUNT.match(amt_s.strip()): - return None - t_amt = re.sub(r"[\s$,]", "", amt_s).replace("−", "-").replace("$", "") - try: - af = round(float(t_amt), 2) - except ValueError: - return None - d_norm = re.sub(r"\s+", "", date_s) - return (d_norm, af) - - -def _row_counter_loose_da3(g): - c = Counter() - if not g or len(g) < 2: - return c - for r in g[1:]: - cells = [str(x or "").strip() for x in r] - if not any(cells): - continue - k = _da3_loose_key(cells) - if k: - c[k] += 1 - return c - - -def _overlap_ratio_counter(c_num, c_den): - tot = sum(c_num.values()) - if tot == 0: - return 0.0 - hit = sum(min(c_num[k], c_den.get(k, 0)) for k in c_num) - return hit / tot - - -def _multiset_leq(c_small, c_big): - return all(c_small[k] <= c_big.get(k, 0) for k in c_small) - - -def _is_da3_header(grid): - if not grid or len(grid[0]) < 3: - return False - h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0]] - if len(h) != 3: - return False - return h[0] == "date" and "description" in h[1] and ( - h[2] == "amount" or h[2].startswith("amount") - ) - - -def _is_da4_date_desc_amount_balance_header(grid): - """Date | …Description… | …Amount… | …Balance… (4-column running-balance register).""" - if not grid or len(grid[0]) < 4: - return False - h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0][:4]] - if h[0] != "date": - return False - if "description" not in h[1]: - return False - # Require "transaction" in the amount header (SEFCU-style) so fee / ledger / - # daily tables with plain "Amount" or "Balance($)" are not deduped. - if "transaction" not in h[2] or "amount" not in h[2]: - return False - if "balance" not in h[3]: - return False - return True - - -_DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$") -_DA3_MONEY_TOKEN = re.compile( - r"(? 0 else 2 - take = min(run_len, cap) - for t in range(take): - out.append(data_rows[i + t]) - i = j - - return out - - -def _da4_register_row_key(cells: List[str]) -> Tuple[str, str, str, str]: - parts = [str(c or "").strip() for c in cells[:4]] - while len(parts) < 4: - parts.append("") - d0, d1, d2, d3 = parts[0], parts[1], parts[2], parts[3] - date_s = re.sub(r"\s+", "", d0) - amt_s = re.sub(r"[\s$,]", "", d2).lower().replace("−", "-") - bal_s = re.sub(r"[\s$,]", "", d3).lower().replace("−", "-") - desc_s = html.unescape(d1) - desc_s = desc_s.replace("`", "'").replace("'", "'") - desc_s = re.sub(r"\s+", "", desc_s.lower()) - return (date_s, desc_s, amt_s, bal_s) - - -def _dedupe_consecutive_identical_da4_register_rows(grid): - """ - Fix SE-CONT / generic: OCR often repeats the same register line twice at page breaks - (identical date, description, amount, and running balance). Real back-to-back - duplicates would show different balances. Collapse consecutive full-row matches; - honour text-layer counts when >1 (same 3-col key as Fix 20). - """ - if not grid or len(grid) < 2: - return grid - if not _is_da4_date_desc_amount_balance_header(grid): - return grid - - data_rows = [] - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - if not any(cells): - continue - data_rows.append(row) - - if not data_rows: - return grid - - out = [grid[0]] - i = 0 - while i < len(data_rows): - row = data_rows[i] - cells = [str(c or "").strip() for c in row] - k4 = _da4_register_row_key(cells) - j = i + 1 - while j < len(data_rows): - ncells = [str(c or "").strip() for c in data_rows[j]] - if _da4_register_row_key(ncells) != k4: - break - j += 1 - run_len = j - i - k3 = _transaction_row_dedupe_key(cells) - tl_confirmed = _tl_freq_context.get(k3, 0) - # Default cap 1 for identical running-balance rows; allow more if PDF text layer says so. - cap = max(tl_confirmed, 1) if tl_confirmed > 0 else 1 - take = min(run_len, cap) - for t in range(take): - out.append(data_rows[i + t]) - i = j - - return out - - -def _row_text_full_ucb(grid, r: int) -> str: - if not grid or r < 0 or r >= len(grid): - return "" - parts = [str(c or "").strip().lower() for c in grid[r]] - return " ".join(parts) - - -def _is_itm_deposit_row_ucb(grid, r: int) -> bool: - if not grid or r >= len(grid) or len(grid[r]) < 2: - return False - desc = str(grid[r][1] or "").strip().lower() - return "itm deposit" in desc - - -def _ucb_verify_electronic_credits_rows(grid, ec_start: int) -> bool: - if ec_start + 2 >= len(grid): - return False - t0 = _row_text_full_ucb(grid, ec_start) - t1 = _row_text_full_ucb(grid, ec_start + 1) - t2 = _row_text_full_ucb(grid, ec_start + 2) - ok0 = "pos return" in t0 or ("pos" in t0 and "return" in t0) - ok1 = "acct fund" in t1 or ("apple" in t1 and "inst" in t1) - ok2 = "dda transfer" in t2 and "in" in t2 - return bool(ok0 and ok1 and ok2) - - -def _ucb_split_grid_deposits_ec_ed(grid): - if not grid or len(grid) < 2: - return None - if not _is_da3_header(grid): - return None - data_start = 1 - i = data_start - while i < len(grid) and _is_itm_deposit_row_ucb(grid, i): - i += 1 - if i == data_start: - return None - if i + 3 > len(grid): - return None - if not _ucb_verify_electronic_credits_rows(grid, i): - return None - ec_end = i + 3 - if ec_end >= len(grid): - return None - hdr = [list(grid[0])] - g_dep = hdr + grid[data_start:i] - g_ec = hdr + grid[i:ec_end] - g_ed = hdr + grid[ec_end:] - if len(g_ed) <= 1: - return None - return g_dep, g_ec, g_ed - - -def _is_strong_ucb_electronic_debit_row(grid, r: int) -> bool: - """True for typical card/ACH debit lines (generic; avoids splitting on POS return / transfer-in credits).""" - t = _row_text_full_ucb(grid, r) - if not t or not t.strip(): - return False - if "pos return" in t: - return False - if "dda transfer" in t and "in" in t: - return False - if "pos purchase" in t: - return True - if "atm withdrawal" in t: - return True - if "atm" in t and "withdrawal" in t: - return True - if "debit card" in t: - return True - if "point of sale" in t: - return True - if "recur payment" in t: - return True - return False - - -def _ucb_grid_has_any_strong_debit_row(grid) -> bool: - if not grid: - return False - for r in range(1, len(grid)): - if not any(str(c or "").strip() for c in grid[r]): - continue - if _is_strong_ucb_electronic_debit_row(grid, r): - return True - return False - - -def _ucb_has_itm_deposit_data_row(grid) -> bool: - for r in range(1, len(grid)): - if _is_itm_deposit_row_ucb(grid, r): - return True - return False - - -def _ucb_table_credits_only_no_strong_debits(grid) -> bool: - """True if DA3 table has real data rows but none match strong electronic-debit patterns.""" - if not grid or len(grid) < 2 or not _is_da3_header(grid): - return False - has_data = False - for r in range(1, len(grid)): - row_txt = _row_text_full_ucb(grid, r).strip() - if not row_txt: - continue - has_data = True - if _is_strong_ucb_electronic_debit_row(grid, r): - return False - return bool(has_data and _ucb_has_itm_deposit_data_row(grid)) - - -def _ucb_html_table_body_looks_itm_credits_only(table_html: str) -> bool: - """ - Detect ITM-only credit block without relying on parsed grid headers (Fix 17c-html). - Conservative: require ITM deposit text and absence of common electronic-debit verbs in the HTML. - """ - if not table_html or " bool: - """ - Rows that belong under Electronic Credits but often land at the end of the debits table after OCR - (Fix 17i). Must not match outgoing debits (POS purchase, Acct Fund SENT, etc.). - """ - if not grid or r < 1 or r >= len(grid): - return False - if _is_strong_ucb_electronic_debit_row(grid, r): - return False - t = _row_text_full_ucb(grid, r) - if "pos return" in t: - return True - if "dda transfer" in t and "in" in t: - return True - if "acct fund" in t and ("inst r" in t or "cupertino" in t): - return True - return False - - -def _ucb_move_trailing_credit_rows_from_ed_to_ec(g_ec, g_ed): - """Fix 17i: credit-like rows that landed in g_ed (POS Return, DDA Transfer IN, Apple INST R) -> g_ec. - - OCR often orders them after outgoing debits on the same date block, so a contiguous tail scan - misses them; we relocate every matching data row while preserving relative order. - """ - if not g_ec or not g_ed or len(g_ed) < 2 or not _is_da3_header(g_ec) or not _is_da3_header(g_ed): - return g_ec, g_ed - credit_indices = [r for r in range(1, len(g_ed)) if _is_ucb_electronic_credit_row_after_split(g_ed, r)] - if not credit_indices: - return g_ec, g_ed - credit_rows = [list(g_ed[r]) for r in credit_indices] - drop = set(credit_indices) - g_ed_new = [list(g_ed[0])] + [list(g_ed[r]) for r in range(1, len(g_ed)) if r not in drop] - g_ec_new = [list(g_ec[0])] + [list(r) for r in g_ec[1:]] + credit_rows - max_c = max(len(r) for r in g_ec_new + g_ed_new) - for row in g_ec_new + g_ed_new: - while len(row) < max_c: - row.append("") - return g_ec_new, g_ed_new - - -def _ucb_split_grid_electronic_credits_debits_after_itm_prefix(grid): - """ - Fix 17b: one fused DA3 table under Electronic Credits (+ optional empty EC/ED headings) where OCR - merged ITM deposits with POS/ATM debits. Split after the leading ITM DEPOSIT run when the next - data row is a strong electronic-debit pattern. - """ - if not grid or len(grid) < 3: - return None - if not _is_da3_header(grid): - return None - data_start = 1 - i = data_start - while i < len(grid) and _is_itm_deposit_row_ucb(grid, i): - i += 1 - if i == data_start or i >= len(grid): - return None - if not _is_strong_ucb_electronic_debit_row(grid, i): - return None - hdr = [list(grid[0])] - g_ec = hdr + grid[data_start:i] - g_ed = hdr + grid[i:] - if len(g_ec) <= 1 or len(g_ed) <= 1: - return None - return g_ec, g_ed - - -def _try_split_ucb_ec_ed_from_fused_table_html( - lead_before_ec: str, table_html: str, had_ed_heading_immediately_before_table: bool -): - try: - p = TableGridParser() - p.feed(table_html) - grid = _build_grid(p.rows) - except Exception: - return None - pair = _ucb_split_grid_electronic_credits_debits_after_itm_prefix(grid) - if pair: - g_ec, g_ed = pair - g_ec, g_ed = _ucb_move_trailing_credit_rows_from_ed_to_ec(g_ec, g_ed) - sect = '
\n\n{title}\n\n
\n\n' - parts = [] - if lead_before_ec and lead_before_ec.strip(): - parts.append(lead_before_ec.rstrip()) - parts.append(sect.format(title="Electronic Credits")) - parts.append(_grid_to_html(g_ec)) - if len(g_ed) > 1: - parts.append(sect.format(title="Electronic Debits")) - parts.append(_grid_to_html(g_ed)) - return "\n\n".join(parts) - - # Fix 17c: headings stacked as EC / ED / table but rows are all credits (e.g. ITM-only) — - # put the table under Electronic Credits, then leave Electronic Debits for continuation. - if had_ed_heading_immediately_before_table and _ucb_table_credits_only_no_strong_debits(grid): - sect = '
\n\n{title}\n\n
\n\n' - parts = [] - if lead_before_ec and lead_before_ec.strip(): - parts.append(lead_before_ec.rstrip()) - parts.extend( - [ - sect.format(title="Electronic Credits"), - _grid_to_html(grid), - sect.format(title="Electronic Debits"), - ] - ) - return "\n\n".join(parts) - - # Fix 17c-html: same reorder when grid header checks fail but raw HTML is clearly ITM-only credits. - if had_ed_heading_immediately_before_table and _ucb_html_table_body_looks_itm_credits_only(table_html): - sect = '
\n\n{title}\n\n
\n\n' - parts = [] - if lead_before_ec and lead_before_ec.strip(): - parts.append(lead_before_ec.rstrip()) - parts.extend( - [ - sect.format(title="Electronic Credits"), - table_html.strip(), - sect.format(title="Electronic Debits"), - ] - ) - return "\n\n".join(parts) - - return None - - -# Centered block titles: align= center OR inline style (Gradio / browsers vary). -_UCB_CENTER_DIV = ( - r'(?:' - r']*align\s*=\s*["\']center["\'][^>]*>' - r'|' - r']*style\s*=\s*["\'][^"\']*text-align\s*:\s*center[^"\']*["\'][^>]*>' - r')' -) - -# Optional empty centered banner only; EC/ED title divs are re-emitted after a successful split -# so we do not duplicate headings. -_UCB_EC_ED_SINGLE_FUSED_TABLE = re.compile( - r'(?P(?:' + _UCB_CENTER_DIV + r'\s*\s*)?)' - r'(?P' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*\s*)' - r'(?P(?:' + _UCB_CENTER_DIV + r'\s*Electronic\s+Debits\s*\s*)?)' - r'(?P]*>.*?
)', - re.DOTALL | re.IGNORECASE, -) - -# Fix 17h: debits DA3 first, then DA3 with leading ITM + duplicate tail (no ED between tables). -_UCB_EC_TWO_DA3_DEBITS_BEFORE_ITM_TAIL = re.compile( - r'(?P(?:' + _UCB_CENTER_DIV + r'\s*\s*)?)' - r'(?P' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*\s*)' - r'(?P(?:' + _UCB_CENTER_DIV + r'\s*Electronic\s+Debits\s*\s*)?)' - r'(?P]*>.*?)' - r'\s*' - r'(?P]*>.*?)', - re.DOTALL | re.IGNORECASE, -) - - -def _ucb_reorder_ec_ed_debits_first_itm_tail_html(text: str) -> str: - """ - Fix 17h: Electronic Credits (+ optional Electronic Debits) then two DA3 tables where the first is - clearly debits and the second begins with a run of ITM DEPOSIT rows (mis-ordered vs credits section). - """ - if not text or "electronic credits" not in text.lower(): - return text - low = text.lower() - if "itm deposit" not in low and "itm dep" not in low: - return text - pat = _UCB_EC_TWO_DA3_DEBITS_BEFORE_ITM_TAIL - _dn = re.compile(r"^\d{1,2}[/\-]\d{2}") - _amt_ok = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$") - - def _key_cells(cells): - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s = re.sub(r"\s+", "", parts[0]) - amt_s = re.sub(r"[\s$,]", "", parts[2]).lower() - amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-") - return date_s, amt_s - - def _row_loose_key(grid, r): - cells = [str(c or "").strip() for c in grid[r]] - if not any(cells): - return None - k = _key_cells(cells) - if not (_dn.match(k[0]) and _amt_ok.match(k[1]) and "." in k[1]): - return None - return k - - m = pat.search(text) - if not m: - return text - g1 = _parse_da3_grid_from_table_html(m.group("t1")) - g2 = _parse_da3_grid_from_table_html(m.group("t2")) - if not g1 or not g2 or not _is_da3_header(g1) or not _is_da3_header(g2): - return text - if not _ucb_grid_has_any_strong_debit_row(g1): - return text - itm_end = 1 - while itm_end < len(g2): - if not any(str(c or "").strip() for c in g2[itm_end]): - itm_end += 1 - continue - if not _is_itm_deposit_row_ucb(g2, itm_end): - break - itm_end += 1 - if itm_end <= 1: - return text - cb = Counter() - for r in range(1, len(g1)): - kk = _row_loose_key(g1, r) - if kk: - cb[kk] += 1 - new_tail = [] - for r in range(itm_end, len(g2)): - if not any(str(c or "").strip() for c in g2[r]): - continue - if _is_itm_deposit_row_ucb(g2, r): - continue - kk = _row_loose_key(g2, r) - if kk and cb.get(kk, 0) > 0: - cb[kk] -= 1 - continue - new_tail.append(list(g2[r])) - g_itm = [list(g2[0])] + [list(g2[r]) for r in range(1, itm_end)] - merged = [list(g1[0])] + [list(r) for r in g1[1:]] + new_tail - max_c = max(len(r) for r in merged) - for row in merged: - while len(row) < max_c: - row.append("") - max_ci = max(len(r) for r in g_itm) - for row in g_itm: - while len(row) < max_ci: - row.append("") - sect = '
\n\n{title}\n\n
\n\n' - parts = [] - lead = m.group("lead") or "" - if lead.strip(): - parts.append(lead.rstrip()) - parts.extend( - [ - sect.format(title="Electronic Credits"), - _grid_to_html(g_itm), - sect.format(title="Electronic Debits"), - _grid_to_html(merged), - ] - ) - new_blob = "\n\n".join(parts) - return text[: m.start()] + new_blob + text[m.end() :] - - -def _ucb_strip_redundant_ed_subset_fragment(text: str) -> str: - """ - Fix 17d / 17f: Remove a duplicate
Electronic Debits
+ when that table's rows are a - loose (date, amount) multiset subset of the DA3 table that appears immediately before this block. - Fix 17f: If the fragment also contains ITM DEPOSIT rows (mis-copied under Debits), ignore those rows - for the subset check so the tail still compares against the preceding debits table. - """ - if not text or "electronic debits" not in text.lower(): - return text - try: - ed_block = re.compile( - _UCB_CENTER_DIV + r'\s*Electronic\s+Debits\s*\s*' - r'(?P]*>.*?
)', - re.DOTALL | re.IGNORECASE, - ) - tbl_pat = re.compile(r']*>.*?', re.DOTALL | re.IGNORECASE) - _DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}") - # Stripped amounts may be "2194.19" (no comma) — must not use comma-group regex. - _AMT_STRIPPED_OK = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$") - - def _loose_da_key_cells(cells): - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s = re.sub(r"\s+", "", parts[0]) - amt_s = re.sub(r"[\s$,]", "", parts[2]).lower() - amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-") - return (date_s, amt_s) - - def _loose_keys_from_grid(g): - if not g or not _is_da3_header(g): - return None - keys = [] - for row in g[1:]: - cells = [str(c or "").strip() for c in row] - if not any(cells): - continue - k = _loose_da_key_cells(cells) - if ( - _DATE_NONEMPTY.match(k[0]) - and _AMT_STRIPPED_OK.match(k[1]) - and "." in k[1] - ): - keys.append(k) - return keys - - def _loose_keys_from_grid_skip_itm(g): - if not g or not _is_da3_header(g): - return None - keys = [] - for r in range(1, len(g)): - if _is_itm_deposit_row_ucb(g, r): - continue - cells = [str(c or "").strip() for c in g[r]] - if not any(cells): - continue - k = _loose_da_key_cells(cells) - if ( - _DATE_NONEMPTY.match(k[0]) - and _AMT_STRIPPED_OK.match(k[1]) - and "." in k[1] - ): - keys.append(k) - return keys - - def _frag_has_itm_data_row(g): - if not g: - return False - for r in range(1, len(g)): - if not any(str(c or "").strip() for c in g[r]): - continue - if _is_itm_deposit_row_ucb(g, r): - return True - return False - - for _ in range(12): - changed = False - for m in ed_block.finditer(text): - before = text[: m.start()] - prev_tbl_m = None - for tm in tbl_pat.finditer(before): - prev_tbl_m = tm - if prev_tbl_m is None: - continue - g_big = _parse_da3_grid_from_table_html(prev_tbl_m.group(0)) - g_frag = _parse_da3_grid_from_table_html(m.group("tbl")) - kb = _loose_keys_from_grid(g_big) - kf = _loose_keys_from_grid(g_frag) - strip = False - if kb and kf and len(kf) <= 24 and len(kf) < len(kb): - cb, cf = Counter(kb), Counter(kf) - if all(cf[k] <= cb.get(k, 0) for k in cf): - strip = True - # Fix 17f: ITM rows duplicated from Credits; debit tail ⊆ previous debits table - if ( - not strip - and kb - and g_frag - and _frag_has_itm_data_row(g_frag) - ): - kf2 = _loose_keys_from_grid_skip_itm(g_frag) - if ( - kf2 - and len(kf2) <= 24 - and len(kf2) < len(kb) - ): - cb, cf2 = Counter(kb), Counter(kf2) - if all(cf2[k] <= cb.get(k, 0) for k in cf2): - strip = True - if strip: - text = text[: m.start()] + text[m.end() :] - changed = True - break - if not changed: - break - return text - except Exception as e: - log.warning("_ucb_strip_redundant_ed_subset_fragment failed: %s", e) - return text - - -def _ucb_prune_ed_fragment_dupes_vs_prev_table(text: str) -> str: - """ - Fix 17g: Under an Electronic Debits + DA3 table, drop leading ITM rows and rows that duplicate - the immediately preceding DA3 table by (date, amount). Skips the case where the previous table is - credits/ITM-only and this table is the main debits block. If ≤6 orphan rows remain, merge into prev - and remove this ED block. - """ - if not text or "electronic debits" not in text.lower(): - return text - try: - ed_block = re.compile( - _UCB_CENTER_DIV + r"\s*Electronic\s+Debits\s*\s*" - r"(?P]*>.*?)", - re.DOTALL | re.IGNORECASE, - ) - tbl_pat = re.compile(r"]*>.*?", re.DOTALL | re.IGNORECASE) - _dn = re.compile(r"^\d{1,2}[/\-]\d{2}") - _amt_ok = re.compile(r"^-?\d+(?:\.\d{1,4})?-?$") - - def _key_cells(cells): - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s = re.sub(r"\s+", "", parts[0]) - amt_s = re.sub(r"[\s$,]", "", parts[2]).lower() - amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-") - return date_s, amt_s - - def _row_loose_key(grid, r): - cells = [str(c or "").strip() for c in grid[r]] - if not any(cells): - return None - k = _key_cells(cells) - if not (_dn.match(k[0]) and _amt_ok.match(k[1]) and "." in k[1]): - return None - return k - - for _ in range(12): - changed = False - for m in ed_block.finditer(text): - before = text[: m.start()] - prev_tbl_m = None - for tm in tbl_pat.finditer(before): - prev_tbl_m = tm - if prev_tbl_m is None: - continue - g_big = _parse_da3_grid_from_table_html(prev_tbl_m.group(0)) - g_frag = _parse_da3_grid_from_table_html(m.group("tbl")) - if ( - not g_big - or not g_frag - or not _is_da3_header(g_big) - or not _is_da3_header(g_frag) - ): - continue - # Legitimate layout: ITM/credits-only table, then Electronic Debits + main debits — do not prune. - if not _ucb_grid_has_any_strong_debit_row(g_big) and _ucb_grid_has_any_strong_debit_row(g_frag): - continue - cb = Counter() - for r in range(1, len(g_big)): - kk = _row_loose_key(g_big, r) - if kk: - cb[kk] += 1 - new_rows = [] - seen_non_itm = False - for r in range(1, len(g_frag)): - if not any(str(c or "").strip() for c in g_frag[r]): - continue - if _is_itm_deposit_row_ucb(g_frag, r): - if not seen_non_itm: - continue - continue - seen_non_itm = True - kk = _row_loose_key(g_frag, r) - if kk and cb.get(kk, 0) > 0: - cb[kk] -= 1 - continue - new_rows.append(list(g_frag[r])) - if not new_rows: - text = text[: m.start()] + text[m.end() :] - changed = True - break - if len(new_rows) <= 6: - merged = [list(g_big[0])] + [list(r) for r in g_big[1:]] + new_rows - max_c = max(len(r) for r in merged) - for row in merged: - while len(row) < max_c: - row.append("") - merged_html = _grid_to_html(merged) - ps, pe = prev_tbl_m.start(), prev_tbl_m.end() - text = text[:ps] + merged_html + text[pe : m.start()] + text[m.end() :] - changed = True - break - hdr = [list(g_frag[0])] - max_c = max(len(hdr[0]), max((len(x) for x in new_rows), default=0)) - for row in hdr + new_rows: - while len(row) < max_c: - row.append("") - new_tbl = _grid_to_html(hdr + new_rows) - full = m.group(0) - tbl = m.group("tbl") - prefix = full[: full.index(tbl)] - text = text[: m.start()] + prefix + new_tbl + text[m.end() :] - changed = True - break - if not changed: - break - return text - except Exception as e: - log.warning("_ucb_prune_ed_fragment_dupes_vs_prev_table failed: %s", e) - return text - - -_UCB_ED_DIV_AND_TABLE = re.compile( - _UCB_CENTER_DIV + r"\s*Electronic\s+Debits\s*\s*(?P]*>.*?)", - re.DOTALL | re.IGNORECASE, -) -_UCB_EC_DIV_AND_TABLE = re.compile( - _UCB_CENTER_DIV + r"\s*Electronic\s+Credits\s*\s*(?P]*>.*?)", - re.DOTALL | re.IGNORECASE, -) - - -def _ucb_relocate_credit_rows_from_ed_tables_to_ec_html(text: str) -> str: - """ - Fix 17j: Credit-side rows (POS Return, DDA Transfer IN, Apple Cash INST R) sometimes appear only under a - duplicate Electronic Debits table, not in the fused EC table — move them to the preceding EC table. - """ - if not text or "electronic debits" not in text.lower(): - return text - try: - while True: - replaced = False - for ed_m in _UCB_ED_DIV_AND_TABLE.finditer(text): - g_ed = _parse_da3_grid_from_table_html(ed_m.group("tbl")) - if not g_ed or len(g_ed) < 2 or not _is_da3_header(g_ed): - continue - credit_ix = [ - r - for r in range(1, len(g_ed)) - if _is_ucb_electronic_credit_row_after_split(g_ed, r) - ] - if not credit_ix: - continue - before = text[: ed_m.start()] - ec_matches = list(_UCB_EC_DIV_AND_TABLE.finditer(before)) - if not ec_matches: - continue - ec_m = ec_matches[-1] - g_ec = _parse_da3_grid_from_table_html(ec_m.group("tbl")) - if not g_ec or len(g_ec) < 2 or not _is_da3_header(g_ec): - continue - drop = set(credit_ix) - credit_rows = [list(g_ed[r]) for r in credit_ix] - g_ed_new = [list(g_ed[0])] + [list(g_ed[r]) for r in range(1, len(g_ed)) if r not in drop] - g_ec_new = [list(g_ec[0])] + [list(r) for r in g_ec[1:]] + credit_rows - max_c = max(len(r) for r in g_ec_new + g_ed_new) - for row in g_ec_new + g_ed_new: - while len(row) < max_c: - row.append("") - new_ec = _grid_to_html(g_ec_new) - if len(g_ed_new) <= 1: - text = ( - text[: ec_m.start("tbl")] - + new_ec - + text[ec_m.end("tbl") : ed_m.start()] - + text[ed_m.end() :] - ) - else: - new_ed = _grid_to_html(g_ed_new) - text = ( - text[: ec_m.start("tbl")] - + new_ec - + text[ec_m.end("tbl") : ed_m.start()] - + text[ed_m.start() : ed_m.start("tbl")] - + new_ed - + text[ed_m.end() :] - ) - log.info("Fix 17j: relocated %d credit row(s) from Electronic Debits to Electronic Credits", len(credit_rows)) - replaced = True - break - if not replaced: - break - return text - except Exception as e: - log.warning("_ucb_relocate_credit_rows_from_ed_tables_to_ec_html failed: %s", e) - return text - - -def _split_ucb_fused_ec_ed_headings_single_table_html(text: str) -> str: - if not text or "electronic credits" not in text.lower(): - return text - if "\s*)' - r'(]*>.*?)' - r'(?:\s*' + _UCB_CENTER_DIV + r'\s*Electronic\s+Credits\s*\s*' - + _UCB_CENTER_DIV + r'\s*Electronic\s+Debits\s*)?', - re.DOTALL | re.IGNORECASE, -) - - -def _split_ucb_deposits_electronic_sections_html(text: str) -> str: - if not text: - return text - if "deposits (continued)" in text.lower(): - - def _repl(m): - prefix = m.group(1) - table_html = m.group(2) - new = _try_split_ucb_deposits_table_to_three(prefix, table_html) - if new is None: - return m.group(0) - return new - - text = _UCB_DEP_CONT_BLOCK.sub(_repl, text) - # Fix 17e: repeat 17h reorder + fused EC/ED fix + duplicate ED strip + 17g prune until stable. - for _ in range(16): - nxt = _ucb_reorder_ec_ed_debits_first_itm_tail_html(text) - nxt = _split_ucb_fused_ec_ed_headings_single_table_html(nxt) - nxt = _ucb_strip_redundant_ed_subset_fragment(nxt) - nxt = _ucb_prune_ed_fragment_dupes_vs_prev_table(nxt) - nxt = _ucb_relocate_credit_rows_from_ed_tables_to_ec_html(nxt) - if nxt == text: - break - text = nxt - return text - - -def _parse_da3_grid_from_table_html(table_html: str): - try: - p = TableGridParser() - p.feed(table_html) - grid = _build_grid(p.rows) - if grid and _is_da3_header(grid): - return grid - except Exception: - pass - return None - - -def _strip_orphan_continued_da3_flatline(text: str) -> str: - if not text or "(continued)" not in text.lower(): - return text - lines = text.splitlines(keepends=True) - out = [] - for line in lines: - core = line.rstrip("\r\n") - date_hits = len(re.findall(r"\d{2}/\d{2}", core)) + len( - re.findall(r"\b\d{2}-\d{2}-\d{2}\b", core) - ) - if ( - len(core) > 80 - and "(continued)" in core.lower() - and "description" in core.lower() - and "amount" in core.lower() - and date_hits >= 3 - ): - continue - out.append(line) - return "".join(out) - - -def _strip_standalone_continued_banner_line(text: str) -> str: - if not text or "(continued)" not in text.lower(): - return text - lines = text.splitlines(keepends=True) - out = [] - for line in lines: - core = line.rstrip("\r\n") - if not core.strip(): - out.append(line) - continue - c = core.lower() - if "$" in core or "=" in core: - out.append(line) - continue - if ( - "(continued)" in c - and "date" not in c - and "description" not in c - and "amount" not in c - and len(re.findall(r"\d{2}/\d{2}", core)) == 0 - and len(core) < 240 - ): - continue - out.append(line) - return "".join(out) - - -def _patch_da3_msft_g_ref_amounts_across_tables(text: str) -> str: - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - - def _collect(): - g_map = {} - ambiguous = set() - for m in tbl_pat.finditer(text): - g = _parse_da3_grid_from_table_html(m.group(0)) - if not g: - continue - for row in g[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - c0, c1, c2 = cells[0], cells[1], cells[2] - if not _DA3_LEADING_DATE.match(c0): - continue - if not _DA3_STRICT_AMOUNT.match(c2): - continue - blob = f"{c0} {c1} {c2}" - for gid in _MSFT_PIN_G_REF.findall(blob): - k = gid.upper() - if k in ambiguous: - continue - trip = (c0, c1, c2) - prev = g_map.get(k) - if prev is not None and prev != trip: - ambiguous.add(k) - g_map.pop(k, None) - elif prev is None: - g_map[k] = trip - return g_map, ambiguous - - g_map, ambiguous = _collect() - if not g_map: - return text - - out_chunks = [] - last = 0 - changed_any = False - for m in tbl_pat.finditer(text): - out_chunks.append(text[last : m.start()]) - table_html = m.group(0) - grid = _parse_da3_grid_from_table_html(table_html) - if not grid: - out_chunks.append(table_html) - last = m.end() - continue - new_grid = [list(grid[0])] - changed = False - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - c0, c1, c2 = cells[0], cells[1], cells[2] - if not _DA3_LEADING_DATE.match(c0): - new_grid.append(row) - continue - if _DA3_STRICT_AMOUNT.match(c2): - new_grid.append(row) - continue - blob = f"{c0} {c1} {c2}" - ids = _MSFT_PIN_G_REF.findall(blob) - if len(ids) != 1: - new_grid.append(row) - continue - k = ids[0].upper() - if k in ambiguous or k not in g_map: - new_grid.append(row) - continue - d0, d1, d2 = g_map[k] - new_grid.append([d0, d1, d2]) - changed = True - if changed: - out_chunks.append(_grid_to_html(new_grid)) - changed_any = True - else: - out_chunks.append(table_html) - last = m.end() - out_chunks.append(text[last:]) - return "".join(out_chunks) if changed_any else text - - -def _trim_adjacent_da3_suffix_duplicating_next_table_prefix(text: str) -> str: - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - - for _ in range(48): - matches = list(pattern.finditer(text)) - if len(matches) < 2: - break - changed = False - for i in range(len(matches) - 1): - g1 = _parse_da3_grid_from_table_html(matches[i].group(0)) - g2 = _parse_da3_grid_from_table_html(matches[i + 1].group(0)) - if not g1 or not g2 or len(g1) < 3 or len(g2) < 2: - continue - d1 = g1[1:] - d2 = g2[1:] - if len(d1) < 2 or len(d2) < 2: - continue - max_k = min(len(d1), len(d2)) - best_k = 0 - for k in range(max_k, 1, -1): - ok = True - for j in range(k): - c1 = [str(x or "").strip() for x in d1[-k + j][:3]] - c2 = [str(x or "").strip() for x in d2[j][:3]] - while len(c1) < 3: - c1.append("") - while len(c2) < 3: - c2.append("") - if _transaction_row_dedupe_key(c1) != _transaction_row_dedupe_key(c2): - ok = False - break - if ok: - best_k = k - break - if best_k < 2: - continue - if len(d1) - best_k < 1: - continue - new_grid = [g1[0]] + d1[:-best_k] - new_html = _grid_to_html(new_grid) - m = matches[i] - text = text[: m.start()] + new_html + text[m.end() :] - changed = True - break - if not changed: - break - return text - - -def _dedupe_da3_by_date_amount_loose(text: str) -> str: - """ - Fix 30: Drop small DA3 table fragments (1-5 data rows) that are a loose - subset of an earlier larger DA3 table, matched by (date, amount) key alone. - - This catches OCR-truncated duplicate rows where description text differs - between the fragment and the anchor table, preventing the exact-key dedup - in _dedupe_subset_transaction_tables_html from firing. - - Example: BoA service-fee page — VIDDYOZE -1.11 appears in a 1-row - fragment table after a full 2-row service-fee table that has the same - row with a much longer description string. - - Guards: - - Fragment must have 1-5 data rows. - - Anchor table must have more data rows than the fragment. - - Every (date, amount) pair in the fragment must be covered by the anchor. - - Date must be non-empty; amount must look like currency. - - Wrapped in try/except (safe no-op on any error). - """ - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - matches = list(pattern.finditer(text)) - if len(matches) < 2: - return text - - _DATE_NONEMPTY = re.compile(r"^\d{1,2}[/\-]\d{2}") - _AMT_NONEMPTY = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$") - - def _da_key(cells): - """Loose (date_norm, amount_norm) key — description ignored.""" - parts = [str(c or "").strip() for c in cells[:3]] - while len(parts) < 3: - parts.append("") - date_s = re.sub(r"\s+", "", parts[0]) - amt_s = re.sub(r"[\s$,]", "", parts[2]).lower() - amt_s = amt_s.replace("\u2212", "-").replace("\u2013", "-") - return (date_s, amt_s) - - # Parse all DA3 tables and collect their (date, amount) key lists. - da3_info = [] - for m in matches: - g = _parse_da3_grid_from_table_html(m.group(0)) - if not g or not _is_da3_header(g) or len(g) < 2: - continue - keys = [] - for row in g[1:]: - cells = [str(c or "").strip() for c in row] - if not any(cells): - continue - k = _da_key(cells) - if _DATE_NONEMPTY.match(k[0]) and _AMT_NONEMPTY.match(k[1]): - keys.append(k) - da3_info.append({"match": m, "keys": keys, "n": len(keys)}) - - if len(da3_info) < 2: - return text - - drop_set = set() - for i in range(1, len(da3_info)): - ti = da3_info[i] - if ti["n"] == 0 or ti["n"] > 5: - continue - if ti["match"].start() in drop_set: - continue - frag_keys = Counter(ti["keys"]) - for j in range(i - 1, -1, -1): - tj = da3_info[j] - if tj["match"].start() in drop_set: - continue - if tj["n"] <= ti["n"]: - continue - big_keys = Counter(tj["keys"]) - # Every (date, amount) pair in the fragment must be covered - # by the anchor table (multiset subset check). - if all(frag_keys[k] <= big_keys.get(k, 0) for k in frag_keys): - drop_set.add(ti["match"].start()) - log.info( - "Fix 30: dropped DA3 fragment (%d rows) as loose " - "(date+amount) subset of larger table (%d rows)", - ti["n"], tj["n"], - ) - break - - if not drop_set: - return text - - out = [] - last = 0 - for m in matches: - out.append(text[last: m.start()]) - if m.start() not in drop_set: - out.append(m.group(0)) - last = m.end() - out.append(text[last:]) - return "".join(out) - - except Exception as e: - log.warning("_dedupe_da3_by_date_amount_loose failed: %s", e) - return text - - -def _fh_withdrawal_column_index(grid) -> Optional[int]: - for row in grid[: min(6, len(grid))]: - for ci, c in enumerate(row): - if str(c or "").strip().upper() == "WITHDRAWAL": - return ci - return None - -def _fh_deposit_column_index(grid) -> Optional[int]: - for row in grid[: min(6, len(grid))]: - for ci, c in enumerate(row): - if str(c or "").strip().upper() == "DEPOSIT": - return ci - return None - - -def _fh_is_deposit_withdrawal_register(grid) -> bool: - if not grid or len(grid) < 2: - return False - blob = " ".join( - " ".join(str(c or "") for c in row) for row in grid[: min(5, len(grid))] - ).upper() - has_date = "DATE" in blob - has_desc = "DESCRIPTION" in blob - has_amt_side = ("WITHDRAWAL" in blob) or ("DEPOSIT" in blob) - return has_date and has_desc and has_amt_side - - -def _drop_da3_duplicate_of_later_deposit_withdrawal_register(text: str) -> str: - """ - Fix FH-1: Vision OCR may emit a spurious DA3 table (subset of withdrawals) before the real - ACCOUNT HISTORY grid. Drop the DA3 when every (date, amount) row is covered by a later - register's WITHDRAWAL column and that register has strictly more withdrawal rows. - """ - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - matches = list(pattern.finditer(text)) - if len(matches) < 2: - return text - - def _parse_grid(html: str): - try: - p = TableGridParser() - p.feed(html) - return _build_grid(p.rows) - except Exception: - return None - - def _norm_da_key_date_amt(date_s: str, amt_s: str) -> Tuple[str, str]: - d = re.sub(r"\s+", "", str(date_s or "").strip()) - a = re.sub(r"[\s$,]", "", str(amt_s or "").strip()).lower() - a = a.replace("−", "-").replace("\u2013", "-") - return (d, a) - - def _frag_counter(g) -> Optional[Counter]: - if not g or not _is_da3_header(g): - return None - c = Counter() - for row in g[1:]: - cells = [str(x or "").strip() for x in row] - while len(cells) < 3: - cells.append("") - if not _DA3_LEADING_DATE.match(cells[0]): - continue - if not _DA3_STRICT_AMOUNT.match(cells[2]): - continue - c[_norm_da_key_date_amt(cells[0], cells[2])] += 1 - if sum(c.values()) < 2: - return None - return c - - def _register_amount_counter(g) -> Optional[Counter]: - if not _fh_is_deposit_withdrawal_register(g): - return None - dci = _fh_deposit_column_index(g) - wci = _fh_withdrawal_column_index(g) - if dci is None and wci is None: - return None - c = Counter() - for row in g[1:]: - cells = [str(x or "").strip() for x in row] - need_cols = [x for x in (dci, wci) if x is not None] - max_idx = max(need_cols) if need_cols else 0 - while len(cells) <= max_idx: - cells.append("") - if not _DA3_LEADING_DATE.match(cells[0]): - continue - for ci in need_cols: - amt = cells[ci] - if not amt.strip(): - continue - if not _DA3_STRICT_AMOUNT.match(amt): - continue - c[_norm_da_key_date_amt(cells[0], amt)] += 1 - if sum(c.values()) < 3: - return None - return c - - grids = [(m, _parse_grid(m.group(0))) for m in matches] - drop_starts = set() - - for i in range(len(grids) - 1): - mi, gi = grids[i] - if mi.start() in drop_starts: - continue - fk = _frag_counter(gi) - if fk is None: - continue - n_frag = sum(fk.values()) - if n_frag > 48: - continue - for j in range(i + 1, len(grids)): - mj, gj = grids[j] - rk = _register_amount_counter(gj) - if rk is None: - continue - n_reg = sum(rk.values()) - if n_reg <= n_frag: - continue - if not all(fk[k] <= rk.get(k, 0) for k in fk): - continue - drop_starts.add(mi.start()) - log.info( - "Fix FH-1: dropped DA3 (%d rows) duplicate of later " - "DEPOSIT/WITHDRAWAL register (%d amount rows)", - n_frag, - n_reg, - ) - break - - if not drop_starts: - return text - - out: List[str] = [] - last = 0 - for m in matches: - out.append(text[last : m.start()]) - if m.start() not in drop_starts: - out.append(m.group(0)) - last = m.end() - out.append(text[last:]) - return "".join(out) - except Exception as e: - log.warning("_drop_da3_duplicate_of_later_deposit_withdrawal_register failed: %s", e) - return text - - -def _dedupe_subset_transaction_tables_html(text: str) -> str: - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - - def _parse_grid(html: str): - try: - p = TableGridParser() - p.feed(html) - return _build_grid(p.rows) - except Exception: - return None - - def _row_set(g): - s = set() - for r in g[1:]: - if not any(str(c or "").strip() for c in r): - continue - cells = [str(c or "").strip() for c in r] - if not any(cells): - continue - s.add(_transaction_row_dedupe_key(cells)) - return s - - def _one_pass(t: str) -> str: - matches = list(pattern.finditer(t)) - if len(matches) < 2: - return t - - grids = [] - for m in matches: - grids.append((m, _parse_grid(m.group(0)))) - - drop_idx = set() - for i in range(1, len(grids)): - if i in drop_idx: - continue - mi, gi = grids[i] - if not _is_da3_header(gi): - continue - si = _row_set(gi) - if not si: - continue - - for j in range(i): - if j in drop_idx: - continue - mj, gj = grids[j] - if not _is_da3_header(gj): - continue - sj = _row_set(gj) - if not sj: - continue - - if sj <= si and len(sj) < len(si): - drop_idx.add(j) - continue - - if si <= sj and len(si) < len(sj): - drop_idx.add(i) - break - - if si == sj: - drop_idx.add(i) - break - - inter = si & sj - ri = len(inter) / len(si) if si else 0.0 - rj = len(inter) / len(sj) if sj else 0.0 - if ri >= 0.88 and len(si) <= len(sj): - drop_idx.add(i) - break - if rj >= 0.88 and len(sj) < len(si): - drop_idx.add(j) - continue - - ci = _row_counter_loose_da3(gi) - cj = _row_counter_loose_da3(gj) - ni, nj = sum(ci.values()), sum(cj.values()) - if ni >= 1 and nj >= 1: - oi_j = _overlap_ratio_counter(ci, cj) - oj_i = _overlap_ratio_counter(cj, ci) - if ( - nj >= 8 - and 3 <= ni <= 14 - and ni < nj - and _multiset_leq(ci, cj) - and oi_j >= 0.9 - ): - drop_idx.add(i) - break - if ni >= 6 and nj >= 6: - if oi_j >= 0.88 and oj_i >= 0.88: - drop_idx.add(i) - break - if _multiset_leq(ci, cj) and ni < nj and oi_j >= 0.9: - drop_idx.add(i) - break - if _multiset_leq(cj, ci) and nj < ni and oj_i >= 0.9: - drop_idx.add(j) - continue - - if not drop_idx: - return t - - out = [] - last = 0 - for idx, m in enumerate(matches): - out.append(t[last : m.start()]) - if idx not in drop_idx: - out.append(m.group(0)) - last = m.end() - out.append(t[last:]) - return "".join(out) - - out = text - for _ in range(12): - nxt = _one_pass(out) - if nxt == out: - break - out = nxt - return out - - -_RE_CENTER_DIV_TWO_DA3_TABLES = re.compile( - r'(\s*[^<]*?\s*)' - r'(]*>.*?)' - r'(\s*]*>.*?)', - re.IGNORECASE | re.DOTALL, -) - - -def _da3_row_credit_vs_debit_category(desc: str) -> str: - d = (desc or "").lower() - if "debit card return" in d: - return "credit" - if "debit card" in d and "return" in d: - return "credit" - if "deposit" in d and "debit card" not in d[:60]: - return "credit" - if "incoming wire" in d or "wire ref" in d: - return "credit" - if "zelle business reversal" in d or ("reversal" in d and "zelle" in d): - return "credit" - if "lrm claims" in d or ("claims" in d and "adj" in d): - return "credit" - if "adp tax" in d and "customer" in d: - return "credit" - if "debit card" in d: - return "debit" - if "ach corp debit" in d: - return "debit" - if "bus online ach settlement" in d: - return "debit" - if "zelle business payment to" in d: - return "debit" - if "visa money transfer debit" in d: - return "debit" - if "telephone payment" in d or "service charges" in d: - return "debit" - if "merch fee" in d: - return "debit" - if "deposit" in d: - return "credit" - return "unknown" - - -def _da3_table_credit_debit_fractions(grid): - deb = cre = unk = 0 - if not grid or len(grid) < 2: - return 0.0, 0.0, 0.0, 0 - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - while len(cells) < 3: - cells.append("") - if not _DA3_LEADING_DATE.match(cells[0]): - continue - if not _DA3_STRICT_AMOUNT.match((cells[2] or "").strip()): - continue - cat = _da3_row_credit_vs_debit_category(cells[1]) - if cat == "debit": - deb += 1 - elif cat == "credit": - cre += 1 - else: - unk += 1 - tot = deb + cre + unk - if tot == 0: - return 0.0, 0.0, 0.0, 0 - return deb / tot, cre / tot, unk / tot, tot - - -def _drop_da3_misplaced_debit_after_deposit_heading_tables(text: str) -> str: - if not text or " str: - window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start] - clean = re.sub(r"<[^>]+>", " ", window) - lines = [ln.strip() for ln in clean.splitlines() if ln.strip()] - for line in reversed(lines): - if len(line) > 3 and "---" not in line: - return line - return "" - - -def _strip_credits_prefix_from_debit_da3_table(text: str) -> str: - """ - Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table - onto the top of a debits/withdrawals DA3 table, strip that prefix. - Safety guards (ALL must pass): - 1. Both tables must be valid DA3 (Date|Description|Amount header). - 2. Target (later) table must follow a debit/withdrawal heading. - 3. Source (earlier) table must follow a credit/deposit heading. - 4. Only the contiguous leading prefix of matching rows is removed. - 5. At least 1 data row must remain in the target after stripping. - 6. Strip count must be >= 1. - 7. Entire function wrapped in try/except — returns input unchanged on error. - """ - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - matches = list(pattern.finditer(text)) - if len(matches) < 2: - return text - - da3_tables = [] - for m in matches: - g = _parse_da3_grid_from_table_html(m.group(0)) - if not g or not _is_da3_header(g): - continue - heading = _get_heading_before_table(text, m.start()) - da3_tables.append({"match": m, "grid": g, "heading": heading}) - - if len(da3_tables) < 2: - return text - - def _row_keys(grid): - keys = set() - for row in grid[1:]: - cells = [str(c or "").strip() for c in row] - if any(cells): - keys.add(_transaction_row_dedupe_key(cells)) - return keys - - drop_map = {} - for i in range(1, len(da3_tables)): - ti = da3_tables[i] - hi = ti["heading"].lower() - if not _DEBIT_HEADING_KEYWORDS.search(hi): - continue - gi = ti["grid"] - data_i = gi[1:] - if len(data_i) < 2: - continue - for j in range(i - 1, -1, -1): - tj = da3_tables[j] - hj = tj["heading"].lower() - if not _CREDIT_HEADING_KEYWORDS.search(hj): - continue - gj = tj["grid"] - keys_j = _row_keys(gj) - if not keys_j: - continue - strip_count = 0 - for row in data_i: - cells = [str(c or "").strip() for c in row] - k = _transaction_row_dedupe_key(cells) - if k in keys_j: - strip_count += 1 - else: - break - if strip_count < 1: - continue - if len(data_i) - strip_count < 1: - continue - drop_map[ti["match"].start()] = strip_count - log.info( - "Fix 28: stripped %d leading credit row(s) from debit table at offset %d", - strip_count, ti["match"].start(), - ) - break - - if not drop_map: - return text - - out_chunks = [] - last = 0 - for m in matches: - out_chunks.append(text[last: m.start()]) - if m.start() in drop_map: - strip_n = drop_map[m.start()] - g = _parse_da3_grid_from_table_html(m.group(0)) - if g and _is_da3_header(g) and len(g) - 1 - strip_n >= 1: - new_grid = [g[0]] + g[1 + strip_n:] - out_chunks.append(_grid_to_html(new_grid)) - else: - out_chunks.append(m.group(0)) - else: - out_chunks.append(m.group(0)) - last = m.end() - out_chunks.append(text[last:]) - return "".join(out_chunks) - - except Exception as e: - log.warning("_strip_credits_prefix_from_debit_da3_table failed: %s", e) - return text - - -# -------------------------- -# Fix 29: Move misplaced debit heading to before its orphan DA3 table -# -------------------------- - -_CENTER_DIV_HDR_RE = re.compile( - r'\s*(.*?)\s*', - re.IGNORECASE | re.DOTALL, -) -_HEADING_SCAN_FORWARD = 800 - - -def _fix29_move_debit_heading_before_orphan_table(text: str) -> str: - """ - Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table - instead of before it, move the heading to its correct position (just before the table). - """ - if not text or "]*>.*?", re.DOTALL | re.IGNORECASE) - matches = list(tbl_pat.finditer(text)) - if len(matches) < 2: - return text - - da3_matches = [m for m in matches if _parse_da3_grid_from_table_html(m.group(0))] - if len(da3_matches) < 2: - return text - - moves = [] - - for i in range(1, len(da3_matches)): - orphan_m = da3_matches[i] - orphan_start = orphan_m.start() - orphan_end = orphan_m.end() - orphan_tbl_html = orphan_m.group(0) - - # Do not treat UCB Electronic Credits ITM-only tables as "orphan debits" needing a heading - # moved ahead of them — Fix 17b leaves ED after the ITM block; moving ED here stacks both - # headings above ITM and leaves debits under the wrong banner. - try: - og = _parse_da3_grid_from_table_html(orphan_tbl_html) - if og and ( - _ucb_table_credits_only_no_strong_debits(og) - or _ucb_html_table_body_looks_itm_credits_only(orphan_tbl_html) - ): - continue - except Exception: - pass - - before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start] - before_clean = re.sub(r"<[^>]+>", " ", before_window) - lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()] - last_meaningful = "" - for l in reversed(lines_before): - if len(l) > 3 and "---" not in l: - last_meaningful = l - break - if _DEBIT_HEADING_KEYWORDS.search(last_meaningful): - continue - - big_before = text[max(0, orphan_start - 1200): orphan_start] - if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)): - continue - - next_tbl_m = tbl_pat.search(text, orphan_end) - - dh_start = dh_end = None - for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD): - div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip() - if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean): - if next_tbl_m and next_tbl_m.start() < div_m.start(): - break - dh_start = div_m.start() - dh_end = div_m.end() - break - - if dh_start is None: - continue - - moves.append((orphan_start, dh_start, dh_end)) - - if not moves: - return text - - for orphan_start, dh_start, dh_end in reversed(moves): - orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None) - if not orphan_m2: - continue - orphan_end = orphan_m2.end() - - heading_html = text[dh_start: dh_end] - gap = text[orphan_end: dh_start] - orphan_html = text[orphan_start: orphan_end] - part_before = text[:orphan_start] - part_after = text[dh_end:] - - text = part_before + heading_html + "\n\n" + orphan_html + gap.rstrip() + part_after - log.info( - "Fix 29: moved debit heading (was at offset %d) to before orphan DA3 table (offset %d)", - dh_start, orphan_start, - ) - - return text - - except Exception as e: - log.warning("_fix29_move_debit_heading_before_orphan_table failed: %s", e) - return text - - -_CHECK_IMAGE_GALLERY_LINE_RE = re.compile( - r"(? str: - """ - Fix MD-3: Replace GLM check-scan page noise (markdown images with bbox + scattered div/line text) - with a single HTML table when the page looks like a check image gallery, not a structured table. - """ - if not text or ("bbox=" not in text and "![](" not in text): - return text - try: - sep = "---page-separator---" - parts = text.split(sep) - out: List[str] = [] - for chunk in parts: - out.append(_maybe_convert_check_image_gallery_chunk(chunk)) - return sep.join(out) - except Exception as e: - log.warning("_normalize_check_image_gallery_pages failed: %s", e) - return text - - -def _maybe_convert_check_image_gallery_chunk(chunk: str) -> str: - if not chunk or not chunk.strip(): - return chunk - if "YOUR CHECKS SEQUENCED" in chunk.upper(): - return chunk - cu = chunk.upper() - # Statement summary / register pages (e.g. First Horizon) may include bbox images; do not - # replace the whole chunk with a synthetic check table. - if "DAILY BALANCE SUMMARY" in cu or "CHECKING ACCOUNT MONTHLY SUMMARY" in cu: - return chunk - if "ACCOUNT SUMMARY" in cu and "NEW BALANCE" in cu and " 24: - return chunk - - matches = list(_CHECK_IMAGE_GALLERY_LINE_RE.finditer(chunk)) - if len(matches) < 2: - return chunk - - bbox_hits = chunk.count("bbox=") - img_hits = len(re.findall(r"!\[\]\s*\(", chunk)) - if bbox_hits < 2 and img_hits < 2: - return chunk - - rows: List[Tuple[str, str, str]] = [] - seen = set() - for m in matches: - key = (m.group(1), m.group(2), m.group(3)) - if key in seen: - continue - seen.add(key) - rows.append(key) - - pos_img = chunk.find("![](") - pos_first = matches[0].start() - if pos_img == -1: - start = pos_first - else: - start = min(pos_img, pos_first) - - prefix = chunk[:start].rstrip() - lines = [ - "", - "", - ] - for num, amt, dt in rows: - lines.append(f"") - lines.append("
Check #AmountDate
{num}{amt}{dt}
") - table_html = "\n".join(lines) - return (prefix + "\n\n" + table_html) if prefix else table_html - - -def _strip_check_image_ocr_junk_html(text: str) -> str: - """ - Fix 31: Remove hallucinated rows on scanned check/receipt regions and normalize amount cells. - """ - if not text or "]*>)([\s\S]*?)()", re.IGNORECASE) - - def _tr_effectively_empty(tr: str) -> bool: - core = re.sub(r"<[^>]+>", " ", tr) - return not core.strip() - - def repl(m) -> str: - open_tag, body, close_tag = m.group(1), m.group(2), m.group(3) - low = body.lower() - if not any( - k in low - for k in ( - "hotline", - "witness la", - "itm checking", - "care none", - "processed ", - "course dut", - "n00590409", - ) - ): - return m.group(0) - kept: List[str] = [] - for trm in re.finditer(r"]*>[\s\S]*?", body, re.IGNORECASE): - tr = trm.group(0) - tl = tr.lower() - if _tr_effectively_empty(tr): - continue - if "[hotline" in tl or "hotline control" in tl: - continue - if "witness la" in tl and ("course" in tl or "dut" in tl): - continue - if "itm checking witness" in tl or ( - "itm checking" in tl and "witness" in tl - ): - continue - if "care none" in tl and "account" in tl: - continue - if "processed " in tl and " @ " in tl and "items" in tl: - continue - if re.search(r"\bN\d{15,}\b", tr): - continue - kept.append(tr) - if not kept: - return m.group(0) - return open_tag + "".join(kept) + close_tag - - text = tbl_pat.sub(repl, text) - text = re.sub(r"\n## UCB\s*\n+(?= str: - """Fix MD-1: common OCR name glitches and double HTML-escaped ampersands in OCR markdown.""" - if not text: - return text - text = re.sub( - r"\bCINTHIAL\s+BARTON\b", - "CINTHIA L BARTON", - text, - flags=re.IGNORECASE, - ) - text = text.replace("&amp;", "&") - return text - - -def _gap_ok_loose_register_pair(ma, mb, text: str) -> bool: - gap = text[ma.end() : mb.start()] - if re.search(r" str: - t = str(s or "").strip().replace("$", "").replace(",", "").replace("−", "-") - if not t or t in ("---", "—"): - return "" - try: - return f"{float(t):.2f}" - except ValueError: - return t - - -def _register_row_loose_key_cells(cells: List[str]) -> Optional[Tuple[str, str, str, str]]: - if len(cells) < 5: - return None - d = str(cells[0]).strip() - if not re.match(r"^\d{1,2}/\d{1,2}(?:/\d{2,4})?$", d): - return None - deb = _norm_register_amount_key(cells[2]) - cre = _norm_register_amount_key(cells[3]) - bal = _norm_register_amount_key(cells[4]) - return (d, deb, cre, bal) - - -def _register_multiset_and_score(grid) -> Tuple[Optional[Counter], int]: - if not grid or len(grid) < 2: - return None, 0 - ncols = max(len(r) for r in grid if isinstance(r, list)) - if ncols < 5: - return None, 0 - c = Counter() - desc_score = 0 - for row in grid[1:]: - cells = [str(x or "").strip() for x in row] - while len(cells) < ncols: - cells.append("") - cells = cells[: max(5, ncols)] - if len(cells) < 5: - continue - k = _register_row_loose_key_cells(cells[:5]) - if not k: - continue - c[k] += 1 - desc_score += len(cells[1]) - if sum(c.values()) < 3: - return None, 0 - return c, desc_score - - -def _dedupe_adjacent_loose_register_duplicate_tables(text: str) -> str: - """ - Fix MD-1: Drop adjacent duplicate bank register blocks when date+debit+credit+balance - multiset matches but description text differs (OCR double-emission). Keeps the richer table. - """ - if not text or "]*>.*?
", re.DOTALL | re.IGNORECASE) - matches = list(tbl_pat.finditer(text)) - if len(matches) < 2: - return text - - def _grid(m): - p = TableGridParser() - p.feed(m.group(0)) - return _build_grid(p.rows) - - keep_indices: List[int] = [0] - for j in range(1, len(matches)): - prev_i = keep_indices[-1] - prev_m, cur_m = matches[prev_i], matches[j] - if not _gap_ok_loose_register_pair(prev_m, cur_m, text): - keep_indices.append(j) - continue - ga, gb = _grid(prev_m), _grid(cur_m) - ma, sa = _register_multiset_and_score(ga) - mb, sb = _register_multiset_and_score(gb) - if ma is None or mb is None or ma != mb: - keep_indices.append(j) - continue - if sb >= sa: - keep_indices[-1] = j - log.info( - "Fix MD-1: loose-dup register — kept table at offset %d (desc score %d vs %d)", - cur_m.start(), - sb, - sa, - ) - else: - log.info( - "Fix MD-1: dropped loose-dup register table at offset %d", - cur_m.start(), - ) - - drop = set(range(len(matches))) - set(keep_indices) - if not drop: - return text - - out: List[str] = [] - pos = 0 - for i, m in enumerate(matches): - out.append(text[pos : m.start()]) - if i not in drop: - out.append(m.group(0)) - pos = m.end() - out.append(text[pos:]) - return "".join(out) - except Exception as e: - log.warning("_dedupe_adjacent_loose_register_duplicate_tables failed: %s", e) - return text - - -def _dedupe_adjacent_duplicate_html_tables(text: str) -> str: - """ - Drop consecutive blocks that are identical after grid parse (same header + row multiset), - with only whitespace between them. Catches OCR echo where the same credits/debits grid is emitted twice. - """ - if not text or "]*>.*?
", re.DOTALL | re.IGNORECASE) - matches = list(tbl_pat.finditer(text)) - if len(matches) < 2: - return text - - def _fingerprint(html: str): - try: - p = TableGridParser() - p.feed(html) - g = _build_grid(p.rows) - if not g or len(g) < 3: - return None - hdr = tuple(str(c or "").strip() for c in g[0]) - body = [] - for r in g[1:]: - if not any(str(c or "").strip() for c in r): - continue - body.append(tuple(str(c or "").strip() for c in r)) - if len(body) < 2: - return None - return (hdr, tuple(sorted(Counter(body).items()))) - except Exception: - return None - - def _gap_ok(ma, mb) -> bool: - gap = text[ma.end() : mb.start()] - return not re.search(r" str: - if not page_md: - return page_md - - # Fresh page: drop any text-layer dedupe caps so the first normalize pass - # (before _patch_ocr_with_textlayer) does not see stale keys from a prior page. - _tl_freq_context.clear() - - page_md = normalize_money_glyphs(page_md) - page_md = _fix_markdown_fidelity_typos_and_escapes(page_md) - - blocks = re.split(r"\n\s*\n", page_md.strip()) - out_blocks = [] - for b in blocks: - if looks_like_markdown_table(b): - out_blocks.append(md_table_to_html(b)) - else: - out_blocks.append(b) - - stabilized = "\n\n".join(out_blocks) - stabilized = convert_plaintext_bank_sections(stabilized) - stabilized = _strip_orphan_continued_da3_flatline(stabilized) - stabilized = _strip_standalone_continued_banner_line(stabilized) - stabilized = normalize_html_tables(stabilized) - stabilized = _normalize_packed_daily_balance_and_checks_summary_tables(stabilized) - stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized) - stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized) - stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized) - stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized) - stabilized = _split_ucb_deposits_electronic_sections_html(stabilized) - stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized) - stabilized = _dedupe_subset_transaction_tables_html(stabilized) - stabilized = _drop_da3_duplicate_of_later_deposit_withdrawal_register(stabilized) # Fix FH-1 - stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30 - stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized) - stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28 - stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29 - stabilized = _strip_orphan_continued_da3_flatline(stabilized) - stabilized = _strip_standalone_continued_banner_line(stabilized) - stabilized = _strip_check_image_ocr_junk_html(stabilized) # Fix 31 - stabilized = _normalize_check_image_gallery_pages(stabilized) # Fix MD-3 - stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized) - return close_unclosed_html(stabilized) - -# -------------------------- -# PDF rendering with padding -# -------------------------- -def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]: - import pymupdf as fitz - from PIL import Image, ImageEnhance - - doc = fitz.open(pdf_path) - page_images: List[str] = [] - page_heights: List[int] = [] - - for i in range(len(doc)): - page = doc[i] - pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False) - - img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) - - if ENABLE_CONTRAST: - img = ImageEnhance.Contrast(img).enhance(1.12) - - w, h = img.size - pad_l = int(w * PAD_LEFT_FRAC) - pad_r = int(w * PAD_RIGHT_FRAC) - pad_t = int(h * PAD_TOP_FRAC) - pad_b = int(h * PAD_BOTTOM_FRAC) - - if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)): - canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255)) - canvas.paste(img, (pad_l, pad_t)) - img = canvas - - img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png") - img.save(img_path, "PNG", compress_level=6) - page_images.append(img_path) - page_heights.append(img.height) - - doc.close() - return page_images, page_heights - -# -------------------------- -# GLM-OCR result extraction -# -------------------------- -def get_page_md_and_regions(page_result): - md = "" - if hasattr(page_result, "markdown_result") and page_result.markdown_result: - md = (page_result.markdown_result or "").strip() - regions = [] - if hasattr(page_result, "json_result"): - jr = page_result.json_result - if isinstance(jr, dict) and "regions" in jr: - regions = jr.get("regions") or [] - elif isinstance(jr, list) and len(jr) > 0: - r = jr[0] if isinstance(jr[0], list) else jr - if isinstance(r, list): - regions = r - elif isinstance(r, dict) and "regions" in r: - regions = r.get("regions") or [] - return md, regions - -# -------------------------- -# Main entry -# -------------------------- -def run_ocr(uploaded_file): - if uploaded_file is None: - return "Please upload a file." - - page_images = [] - try: - path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file) - is_pdf = path.lower().endswith(".pdf") - is_nfcu_doc = _is_nfcu_document(path) if is_pdf else False - parser = get_parser() - - page_heights = [] - - if is_pdf: - page_images, page_heights = render_pdf_pages_to_images(path) - results = parser.parse(page_images) - else: - page_images = [path] - page_heights = [1000] - results = parser.parse(path) - - if not isinstance(results, list): - results = [results] - - all_pages = [] - for page_num, page_result in enumerate(results): - # Header normalize runs before body patch; do not reuse prior page caps. - _tl_freq_context.clear() - page_md, regions = get_page_md_and_regions(page_result) - img_h = page_heights[page_num] if page_num < len(page_heights) else 1000 - header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h) - - he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC - fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC) - - he = max(0.02, min(0.25, he)) - fs = max(0.75, min(0.98, fs)) - - parts = [] - - hdr = "" - if is_pdf: - hdr = extract_zone_text_pdf(path, page_num, 0, he) - if not (hdr and hdr.strip()): - hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC) - if not (hdr and hdr.strip()) and page_num < len(page_images): - hdr = ocr_zone(page_images[page_num], 0, he) - if hdr and hdr.strip(): - parts.append(normalize_html_tables(fix_account_number(normalize_money_glyphs(hdr.strip())))) - - if page_md and page_md.strip(): - stabilized = stabilize_tables_and_text(page_md.strip()) - if is_pdf and not is_nfcu_doc: - stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num) - stabilized = _strip_orphan_continued_da3_flatline(stabilized) - stabilized = _strip_standalone_continued_banner_line(stabilized) - stabilized = normalize_html_tables(stabilized) - stabilized = _normalize_packed_daily_balance_and_checks_summary_tables(stabilized) - stabilized = _dedupe_adjacent_duplicate_html_tables(stabilized) - stabilized = _dedupe_adjacent_loose_register_duplicate_tables(stabilized) - stabilized = _fix_markdown_fidelity_typos_and_escapes(stabilized) - stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized) - stabilized = _split_ucb_deposits_electronic_sections_html(stabilized) - stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized) - stabilized = _dedupe_subset_transaction_tables_html(stabilized) - stabilized = _drop_da3_duplicate_of_later_deposit_withdrawal_register(stabilized) # Fix FH-1 - stabilized = _dedupe_da3_by_date_amount_loose(stabilized) # Fix 30 - stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized) - stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28 - stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29 - stabilized = _strip_standalone_continued_banner_line(stabilized) - stabilized = _strip_check_image_ocr_junk_html(stabilized) # Fix 31 - stabilized = _normalize_check_image_gallery_pages(stabilized) # Fix MD-3 - elif is_pdf and is_nfcu_doc: - nfcu_summary = _extract_nfcu_summary_table(path, page_num) - if nfcu_summary: - stabilized = _replace_nfcu_summary_table(stabilized, nfcu_summary) - - if is_pdf: - try: - needs_checks = 'class="jb-summary-checks"' not in stabilized - needs_dab = "daily account balance" not in stabilized.lower() - if needs_checks or needs_dab: - _txt4d = "" - try: - from pdfminer.high_level import extract_text as _pm_extract - _txt4d = _pm_extract(path, page_numbers=[page_num]) - except Exception: - pass - if not _txt4d: - try: - import pymupdf as _fitz4d - _d4 = _fitz4d.open(path) - _txt4d = _d4[page_num].get_text() - _d4.close() - except Exception: - pass - if _txt4d: - jb_extra = _extract_jb_summary_sections(_txt4d, needs_checks, needs_dab) - if jb_extra: - stabilized = stabilized.rstrip() + "\n\n" + jb_extra - except Exception: - pass - - parts.append(stabilized) - - if ENABLE_FOOTER_OCR and page_num < len(page_images): - ftr = "" - if is_pdf: - ftr = extract_zone_text_pdf(path, page_num, fs, 1.0) - if not (ftr and ftr.strip()): - ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0) - if not (ftr and ftr.strip()): - ftr = ocr_zone(page_images[page_num], fs, 1.0) - if ftr and ftr.strip(): - ftr_clean = normalize_money_glyphs(ftr.strip()) - - ftr_first_line = next( - (ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()), - "" - ) - already_present = ftr_first_line and any( - ftr_first_line in part.lower() for part in parts - ) - - _footer_date_re = re.compile(r"\b\d{1,2}[-/]\d{2}\b") - _footer_amt_re = re.compile(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b") - _date_hits = len(_footer_date_re.findall(ftr_clean)) - _amt_hits = len(_footer_amt_re.findall(ftr_clean)) - is_txn_dump = _date_hits >= 3 and _amt_hits >= 3 - - if not already_present and not is_txn_dump: - parts.append(ftr_clean) - - if parts: - all_pages.append("\n\n".join(parts)) - - merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)" - if all_pages and merged and not merged.startswith("Error") and "