Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -9,7 +9,7 @@ Primary goal for reconcile rate:
|
|
| 9 |
- keep tables as tables (convert markdown pipe tables -> HTML table)
|
| 10 |
- return ---page-separator--- between pages
|
| 11 |
- normalize HTML tables so header/data columns align (expand colspan/rowspan)
|
| 12 |
-
-
|
| 13 |
"""
|
| 14 |
|
| 15 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -51,39 +51,29 @@ FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
|
|
| 51 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 52 |
# ============================================================
|
| 53 |
|
| 54 |
-
# 1) GLM-OCR MaaS API key
|
| 55 |
-
# IMPORTANT: Do NOT hard-code secrets in a public Space.
|
| 56 |
-
# If your Space is public, switch to HF Secrets instead.
|
| 57 |
GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
|
| 58 |
|
| 59 |
-
# 2) Render quality (higher = better OCR for small/right-aligned digits; slower)
|
| 60 |
RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing
|
| 61 |
|
| 62 |
-
# 3) Add padding to protect columns near edges (Balance is usually right-most)
|
| 63 |
PAD_LEFT_FRAC = 0.02
|
| 64 |
-
PAD_RIGHT_FRAC = 0.06
|
| 65 |
PAD_TOP_FRAC = 0.01
|
| 66 |
PAD_BOTTOM_FRAC = 0.01
|
| 67 |
|
| 68 |
-
# 4) Mild contrast boost (helps faint gray text)
|
| 69 |
ENABLE_CONTRAST = True
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
PDF_HEADER_BAND_FRAC = 0.10 # PDF text fallback: take top 10% words if clip returns empty
|
| 74 |
|
| 75 |
-
# Footer: disabled by default to avoid duplicating what GLM already returns
|
| 76 |
ENABLE_FOOTER_OCR = False
|
| 77 |
-
PDF_FOOTER_BAND_FRAC = 0.88
|
| 78 |
|
| 79 |
-
# MaaS minimum image sizes for crops; we pad if needed
|
| 80 |
MIN_CROP_HEIGHT = 112
|
| 81 |
MIN_CROP_PIXELS = 112 * 112
|
| 82 |
|
| 83 |
# ============================================================
|
| 84 |
|
| 85 |
|
| 86 |
-
# Single shared parser to avoid re-init per request
|
| 87 |
_parser = None
|
| 88 |
|
| 89 |
def get_parser():
|
|
@@ -110,7 +100,6 @@ try:
|
|
| 110 |
except Exception:
|
| 111 |
pass
|
| 112 |
|
| 113 |
-
# Best-effort formatter tweak: avoid stripping header/footer labels
|
| 114 |
try:
|
| 115 |
with open(FORMATTER_PATH, "r") as f:
|
| 116 |
source = f.read()
|
|
@@ -128,7 +117,6 @@ except Exception:
|
|
| 128 |
# Header/footer helpers
|
| 129 |
# --------------------------
|
| 130 |
def get_header_footer_zones(regions, norm_height=1000):
|
| 131 |
-
"""Infer header/footer extents from bbox regions if present."""
|
| 132 |
if not regions:
|
| 133 |
return None, None
|
| 134 |
y_tops, y_bottoms = [], []
|
|
@@ -143,7 +131,6 @@ def get_header_footer_zones(regions, norm_height=1000):
|
|
| 143 |
|
| 144 |
|
| 145 |
def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 146 |
-
"""Extract text from a horizontal band using a clip rect (works if PDF has text layer)."""
|
| 147 |
try:
|
| 148 |
import pymupdf as fitz
|
| 149 |
doc = fitz.open(pdf_path)
|
|
@@ -158,7 +145,6 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
| 158 |
|
| 159 |
|
| 160 |
def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
| 161 |
-
"""Extract words whose bbox intersects a vertical band (robust fallback)."""
|
| 162 |
try:
|
| 163 |
import pymupdf as fitz
|
| 164 |
doc = fitz.open(pdf_path)
|
|
@@ -180,7 +166,6 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
| 180 |
|
| 181 |
|
| 182 |
def ocr_zone(image_path, y_start_frac, y_end_frac):
|
| 183 |
-
"""Run OCR on a horizontal band. Pads small crops to meet MaaS minimum size."""
|
| 184 |
zone_name = "header" if y_end_frac < 0.5 else "footer"
|
| 185 |
try:
|
| 186 |
from PIL import Image
|
|
@@ -227,7 +212,6 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
|
|
| 227 |
|
| 228 |
|
| 229 |
def fix_account_number(hdr: str) -> str:
|
| 230 |
-
"""Fix common account-number formatting issues."""
|
| 231 |
if not hdr:
|
| 232 |
return hdr
|
| 233 |
if "Account Number:" in hdr and "Account Number: " not in hdr:
|
|
@@ -246,7 +230,6 @@ def fix_account_number(hdr: str) -> str:
|
|
| 246 |
# Table stabilization helpers
|
| 247 |
# --------------------------
|
| 248 |
def close_unclosed_html(md: str) -> str:
|
| 249 |
-
"""Close unclosed <table>/<tr>/<td> tags to prevent bleed."""
|
| 250 |
if not md:
|
| 251 |
return md
|
| 252 |
open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
|
|
@@ -263,46 +246,58 @@ def close_unclosed_html(md: str) -> str:
|
|
| 263 |
return md
|
| 264 |
|
| 265 |
|
| 266 |
-
def
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
"""
|
| 275 |
-
if not text:
|
| 276 |
-
return text
|
| 277 |
|
| 278 |
-
# DESCRIPTION + (Beginning/Ending Balance) glued
|
| 279 |
-
text = re.sub(
|
| 280 |
-
r"(>[^<]*\bDESCRIPTION)\s*(Beginning Balance|Ending Balance)\b([^<]*<)",
|
| 281 |
-
r"\1\3",
|
| 282 |
-
text,
|
| 283 |
-
flags=re.IGNORECASE,
|
| 284 |
-
)
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
|
| 303 |
-
# ----
|
| 304 |
class TableGridParser(HTMLParser):
|
| 305 |
-
"""Parse <table>
|
| 306 |
|
| 307 |
def __init__(self):
|
| 308 |
super().__init__()
|
|
@@ -311,6 +306,7 @@ class TableGridParser(HTMLParser):
|
|
| 311 |
self._cell_text = []
|
| 312 |
self._colspan = 1
|
| 313 |
self._rowspan = 1
|
|
|
|
| 314 |
|
| 315 |
def handle_starttag(self, tag, attrs):
|
| 316 |
if tag == "tr":
|
|
@@ -320,20 +316,22 @@ class TableGridParser(HTMLParser):
|
|
| 320 |
self._colspan = max(1, int(attrs_d.get("colspan", 1)))
|
| 321 |
self._rowspan = max(1, int(attrs_d.get("rowspan", 1)))
|
| 322 |
self._cell_text = []
|
|
|
|
| 323 |
|
| 324 |
def handle_endtag(self, tag):
|
| 325 |
if tag in ("td", "th"):
|
| 326 |
text = "".join(self._cell_text).strip().replace("\n", " ")
|
| 327 |
self._current_row.append((text, self._colspan, self._rowspan))
|
|
|
|
| 328 |
elif tag == "tr":
|
| 329 |
self.rows.append(self._current_row)
|
| 330 |
|
| 331 |
def handle_data(self, data):
|
| 332 |
-
self.
|
|
|
|
| 333 |
|
| 334 |
|
| 335 |
def _build_grid(rows_data):
|
| 336 |
-
"""Expand colspan/rowspan into a rectangular grid."""
|
| 337 |
if not rows_data:
|
| 338 |
return []
|
| 339 |
blocked = defaultdict(set)
|
|
@@ -360,13 +358,12 @@ def _build_grid(rows_data):
|
|
| 360 |
|
| 361 |
|
| 362 |
def _grid_to_html(grid):
|
| 363 |
-
"""Emit a normalized table without colspan/rowspan."""
|
| 364 |
if not grid:
|
| 365 |
return ""
|
| 366 |
lines = ["<table>"]
|
| 367 |
-
for
|
| 368 |
lines.append("<tr>")
|
| 369 |
-
tag = "th" if
|
| 370 |
for cell in row:
|
| 371 |
escaped = (cell or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 372 |
lines.append(f"<{tag}>{escaped}</{tag}>")
|
|
@@ -375,90 +372,107 @@ def _grid_to_html(grid):
|
|
| 375 |
return "\n".join(lines)
|
| 376 |
|
| 377 |
|
| 378 |
-
def
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
result = []
|
| 385 |
-
last_end = 0
|
| 386 |
|
| 387 |
-
for match in pattern.finditer(text):
|
| 388 |
-
result.append(text[last_end : match.start()])
|
| 389 |
-
table_html = match.group(0)
|
| 390 |
-
try:
|
| 391 |
-
parser = TableGridParser()
|
| 392 |
-
parser.feed(table_html)
|
| 393 |
-
if parser.rows:
|
| 394 |
-
grid = _build_grid(parser.rows)
|
| 395 |
-
result.append(_grid_to_html(grid))
|
| 396 |
-
else:
|
| 397 |
-
result.append(table_html)
|
| 398 |
-
except Exception:
|
| 399 |
-
result.append(table_html)
|
| 400 |
-
last_end = match.end()
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
|
|
|
|
|
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
if len(lines) < 2:
|
| 410 |
-
return False
|
| 411 |
-
if "|" not in lines[0]:
|
| 412 |
-
return False
|
| 413 |
-
sep = lines[1].replace(" ", "")
|
| 414 |
-
return ("---" in sep) and ("|" in sep)
|
| 415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
-
|
| 418 |
-
""
|
| 419 |
-
|
| 420 |
-
if
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
-
|
| 424 |
-
row = row.strip()
|
| 425 |
-
if row.startswith("|"):
|
| 426 |
-
row = row[1:]
|
| 427 |
-
if row.endswith("|"):
|
| 428 |
-
row = row[:-1]
|
| 429 |
-
return [p.strip() for p in row.split("|")]
|
| 430 |
|
| 431 |
-
header = split_row(lines[0])
|
| 432 |
-
body_lines = [ln for ln in lines[2:] if "|" in ln]
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
|
|
|
|
|
|
| 442 |
|
|
|
|
|
|
|
|
|
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
return token.replace("O", "0").replace("o", "0")
|
| 454 |
-
t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
|
| 455 |
-
return t
|
| 456 |
|
| 457 |
|
| 458 |
def stabilize_tables_and_text(page_md: str) -> str:
|
| 459 |
-
"""Convert markdown pipe tables to HTML, clean header artifacts, normalize tables, and close tags."""
|
| 460 |
if not page_md:
|
| 461 |
return page_md
|
|
|
|
| 462 |
page_md = normalize_money_glyphs(page_md)
|
| 463 |
|
| 464 |
blocks = re.split(r"\n\s*\n", page_md.strip())
|
|
@@ -471,13 +485,10 @@ def stabilize_tables_and_text(page_md: str) -> str:
|
|
| 471 |
|
| 472 |
stabilized = "\n\n".join(out_blocks)
|
| 473 |
|
| 474 |
-
#
|
| 475 |
-
stabilized =
|
| 476 |
-
|
| 477 |
-
# 2) Normalize tables to a rectangular grid (expand colspan/rowspan)
|
| 478 |
-
stabilized = expand_colspan_rowspan_in_tables(stabilized)
|
| 479 |
|
| 480 |
-
#
|
| 481 |
return close_unclosed_html(stabilized)
|
| 482 |
|
| 483 |
|
|
@@ -577,13 +588,11 @@ def run_ocr(uploaded_file):
|
|
| 577 |
he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
|
| 578 |
fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
|
| 579 |
|
| 580 |
-
# clamp
|
| 581 |
he = max(0.02, min(0.25, he))
|
| 582 |
fs = max(0.75, min(0.98, fs))
|
| 583 |
|
| 584 |
parts = []
|
| 585 |
|
| 586 |
-
# Header inclusion: PDF text -> band words -> OCR band
|
| 587 |
hdr = ""
|
| 588 |
if is_pdf:
|
| 589 |
hdr = extract_zone_text_pdf(path, page_num, 0, he)
|
|
@@ -594,11 +603,9 @@ def run_ocr(uploaded_file):
|
|
| 594 |
if hdr and hdr.strip():
|
| 595 |
parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
|
| 596 |
|
| 597 |
-
# Main OCR markdown, stabilized
|
| 598 |
if page_md and page_md.strip():
|
| 599 |
parts.append(stabilize_tables_and_text(page_md.strip()))
|
| 600 |
|
| 601 |
-
# Optional footer
|
| 602 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 603 |
ftr = ""
|
| 604 |
if is_pdf:
|
|
@@ -621,7 +628,6 @@ def run_ocr(uploaded_file):
|
|
| 621 |
return f"Error: {e}\n\n{traceback.format_exc()}"
|
| 622 |
|
| 623 |
finally:
|
| 624 |
-
# cleanup rendered images
|
| 625 |
for p in page_images:
|
| 626 |
try:
|
| 627 |
if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
|
|
|
|
| 9 |
- keep tables as tables (convert markdown pipe tables -> HTML table)
|
| 10 |
- return ---page-separator--- between pages
|
| 11 |
- normalize HTML tables so header/data columns align (expand colspan/rowspan)
|
| 12 |
+
- generically repair "Beginning Balance" when OCR glues it into header (e.g. Chase)
|
| 13 |
"""
|
| 14 |
|
| 15 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 51 |
# HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
|
| 52 |
# ============================================================
|
| 53 |
|
|
|
|
|
|
|
|
|
|
| 54 |
GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
|
| 55 |
|
|
|
|
| 56 |
RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing
|
| 57 |
|
|
|
|
| 58 |
PAD_LEFT_FRAC = 0.02
|
| 59 |
+
PAD_RIGHT_FRAC = 0.06
|
| 60 |
PAD_TOP_FRAC = 0.01
|
| 61 |
PAD_BOTTOM_FRAC = 0.01
|
| 62 |
|
|
|
|
| 63 |
ENABLE_CONTRAST = True
|
| 64 |
|
| 65 |
+
DEFAULT_ZONE_FRAC = 0.12
|
| 66 |
+
PDF_HEADER_BAND_FRAC = 0.10
|
|
|
|
| 67 |
|
|
|
|
| 68 |
ENABLE_FOOTER_OCR = False
|
| 69 |
+
PDF_FOOTER_BAND_FRAC = 0.88
|
| 70 |
|
|
|
|
| 71 |
MIN_CROP_HEIGHT = 112
|
| 72 |
MIN_CROP_PIXELS = 112 * 112
|
| 73 |
|
| 74 |
# ============================================================
|
| 75 |
|
| 76 |
|
|
|
|
| 77 |
_parser = None
|
| 78 |
|
| 79 |
def get_parser():
|
|
|
|
| 100 |
except Exception:
|
| 101 |
pass
|
| 102 |
|
|
|
|
| 103 |
try:
|
| 104 |
with open(FORMATTER_PATH, "r") as f:
|
| 105 |
source = f.read()
|
|
|
|
| 117 |
# Header/footer helpers
|
| 118 |
# --------------------------
|
| 119 |
def get_header_footer_zones(regions, norm_height=1000):
|
|
|
|
| 120 |
if not regions:
|
| 121 |
return None, None
|
| 122 |
y_tops, y_bottoms = [], []
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
|
|
| 134 |
try:
|
| 135 |
import pymupdf as fitz
|
| 136 |
doc = fitz.open(pdf_path)
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
|
|
|
|
| 148 |
try:
|
| 149 |
import pymupdf as fitz
|
| 150 |
doc = fitz.open(pdf_path)
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
def ocr_zone(image_path, y_start_frac, y_end_frac):
|
|
|
|
| 169 |
zone_name = "header" if y_end_frac < 0.5 else "footer"
|
| 170 |
try:
|
| 171 |
from PIL import Image
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
def fix_account_number(hdr: str) -> str:
|
|
|
|
| 215 |
if not hdr:
|
| 216 |
return hdr
|
| 217 |
if "Account Number:" in hdr and "Account Number: " not in hdr:
|
|
|
|
| 230 |
# Table stabilization helpers
|
| 231 |
# --------------------------
|
| 232 |
def close_unclosed_html(md: str) -> str:
|
|
|
|
| 233 |
if not md:
|
| 234 |
return md
|
| 235 |
open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
|
|
|
|
| 246 |
return md
|
| 247 |
|
| 248 |
|
| 249 |
+
def looks_like_markdown_table(block: str) -> bool:
|
| 250 |
+
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 251 |
+
if len(lines) < 2:
|
| 252 |
+
return False
|
| 253 |
+
if "|" not in lines[0]:
|
| 254 |
+
return False
|
| 255 |
+
sep = lines[1].replace(" ", "")
|
| 256 |
+
return ("---" in sep) and ("|" in sep)
|
|
|
|
|
|
|
|
|
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
+
def md_table_to_html(block: str) -> str:
|
| 260 |
+
lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
|
| 261 |
+
if len(lines) < 2:
|
| 262 |
+
return block
|
| 263 |
+
|
| 264 |
+
def split_row(row: str):
|
| 265 |
+
row = row.strip()
|
| 266 |
+
if row.startswith("|"):
|
| 267 |
+
row = row[1:]
|
| 268 |
+
if row.endswith("|"):
|
| 269 |
+
row = row[:-1]
|
| 270 |
+
return [p.strip() for p in row.split("|")]
|
| 271 |
+
|
| 272 |
+
header = split_row(lines[0])
|
| 273 |
+
body_lines = [ln for ln in lines[2:] if "|" in ln]
|
| 274 |
+
|
| 275 |
+
html_rows = []
|
| 276 |
+
html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
|
| 277 |
+
for ln in body_lines:
|
| 278 |
+
cols = split_row(ln)
|
| 279 |
+
if len(cols) < len(header):
|
| 280 |
+
cols += [""] * (len(header) - len(cols))
|
| 281 |
+
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[:len(header)]) + "</tr>")
|
| 282 |
+
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def normalize_money_glyphs(text: str) -> str:
|
| 286 |
+
if not text:
|
| 287 |
+
return text
|
| 288 |
+
t = text.replace("−", "-").replace("–", "-").replace("—", "-")
|
| 289 |
+
t = re.sub(r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)", r"-\1\2", t)
|
| 290 |
|
| 291 |
+
def o_to_zero(m):
|
| 292 |
+
token = m.group(0)
|
| 293 |
+
return token.replace("O", "0").replace("o", "0")
|
| 294 |
+
t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
|
| 295 |
+
return t
|
| 296 |
|
| 297 |
|
| 298 |
+
# ---- Generic HTML table normalizer (colspan/rowspan + beginning-balance repair) ----
|
| 299 |
class TableGridParser(HTMLParser):
|
| 300 |
+
"""Parse a <table> into rows of (text, colspan, rowspan) per cell."""
|
| 301 |
|
| 302 |
def __init__(self):
|
| 303 |
super().__init__()
|
|
|
|
| 306 |
self._cell_text = []
|
| 307 |
self._colspan = 1
|
| 308 |
self._rowspan = 1
|
| 309 |
+
self._in_cell = False
|
| 310 |
|
| 311 |
def handle_starttag(self, tag, attrs):
|
| 312 |
if tag == "tr":
|
|
|
|
| 316 |
self._colspan = max(1, int(attrs_d.get("colspan", 1)))
|
| 317 |
self._rowspan = max(1, int(attrs_d.get("rowspan", 1)))
|
| 318 |
self._cell_text = []
|
| 319 |
+
self._in_cell = True
|
| 320 |
|
| 321 |
def handle_endtag(self, tag):
|
| 322 |
if tag in ("td", "th"):
|
| 323 |
text = "".join(self._cell_text).strip().replace("\n", " ")
|
| 324 |
self._current_row.append((text, self._colspan, self._rowspan))
|
| 325 |
+
self._in_cell = False
|
| 326 |
elif tag == "tr":
|
| 327 |
self.rows.append(self._current_row)
|
| 328 |
|
| 329 |
def handle_data(self, data):
|
| 330 |
+
if self._in_cell:
|
| 331 |
+
self._cell_text.append(data)
|
| 332 |
|
| 333 |
|
| 334 |
def _build_grid(rows_data):
|
|
|
|
| 335 |
if not rows_data:
|
| 336 |
return []
|
| 337 |
blocked = defaultdict(set)
|
|
|
|
| 358 |
|
| 359 |
|
| 360 |
def _grid_to_html(grid):
|
|
|
|
| 361 |
if not grid:
|
| 362 |
return ""
|
| 363 |
lines = ["<table>"]
|
| 364 |
+
for r, row in enumerate(grid):
|
| 365 |
lines.append("<tr>")
|
| 366 |
+
tag = "th" if r == 0 else "td"
|
| 367 |
for cell in row:
|
| 368 |
escaped = (cell or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
| 369 |
lines.append(f"<{tag}>{escaped}</{tag}>")
|
|
|
|
| 372 |
return "\n".join(lines)
|
| 373 |
|
| 374 |
|
| 375 |
+
def _looks_like_amount(s: str) -> bool:
|
| 376 |
+
if not s:
|
| 377 |
+
return False
|
| 378 |
+
s2 = s.strip()
|
| 379 |
+
# $2,006.07 or 2006.07 or -515.00 etc.
|
| 380 |
+
return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s2) is not None
|
|
|
|
|
|
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
+
def _normalize_transaction_header_and_beginning_balance(grid):
|
| 384 |
+
"""
|
| 385 |
+
Generic fix:
|
| 386 |
+
If the header row contains glued 'Beginning Balance' and/or glued starting balance value,
|
| 387 |
+
extract that starting balance and insert a synthetic first data row:
|
| 388 |
+
['', 'Beginning Balance', '', '', '<balance>']
|
| 389 |
+
Then clean the header cells to be just DESCRIPTION and BALANCE.
|
| 390 |
+
Works across PDFs because it only triggers when these exact generic artifacts are present.
|
| 391 |
+
"""
|
| 392 |
+
if not grid or not grid[0]:
|
| 393 |
+
return grid
|
| 394 |
|
| 395 |
+
header = [c.strip() for c in grid[0]]
|
| 396 |
+
header_join = " | ".join(h.upper() for h in header)
|
| 397 |
|
| 398 |
+
is_txn_like = ("DATE" in header_join and "AMOUNT" in header_join and "BALANCE" in header_join)
|
| 399 |
+
if not is_txn_like:
|
| 400 |
+
return grid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
+
# Find starting balance embedded in header (common OCR artifact: "BALANCE$3,447.10")
|
| 403 |
+
start_balance = None
|
| 404 |
+
for i, cell in enumerate(header):
|
| 405 |
+
m = re.search(r"\bBALANCE\b\s*\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)", cell, flags=re.IGNORECASE)
|
| 406 |
+
if m:
|
| 407 |
+
start_balance = m.group(1)
|
| 408 |
+
header[i] = re.sub(r"\bBALANCE\b.*", "BALANCE", cell, flags=re.IGNORECASE).strip() or "BALANCE"
|
| 409 |
+
# Clean DESCRIPTIONBeginning Balance / DESCRIPTIONEnding Balance
|
| 410 |
+
if re.search(r"\bDESCRIPTION\b", cell, flags=re.IGNORECASE) and re.search(r"\bBeginning Balance\b|\bEnding Balance\b", cell, flags=re.IGNORECASE):
|
| 411 |
+
header[i] = "DESCRIPTION"
|
| 412 |
+
|
| 413 |
+
# If header has "Beginning Balance" glued elsewhere, but balance value not found, don't invent a value.
|
| 414 |
+
# Also: if the table already contains a beginning-balance style row, don't add one.
|
| 415 |
+
body = grid[1:] if len(grid) > 1 else []
|
| 416 |
+
has_begin_row = any(
|
| 417 |
+
any(isinstance(c, str) and "beginning balance" in c.lower() for c in row)
|
| 418 |
+
for row in body
|
| 419 |
+
)
|
| 420 |
|
| 421 |
+
# Replace header row in grid
|
| 422 |
+
grid[0] = header + ([""] * (len(grid[0]) - len(header))) if len(header) < len(grid[0]) else header[: len(grid[0])]
|
| 423 |
+
|
| 424 |
+
if start_balance and not has_begin_row:
|
| 425 |
+
# Insert synthetic beginning balance row aligned to typical 5-col txn tables
|
| 426 |
+
cols = len(grid[0])
|
| 427 |
+
new_row = [""] * cols
|
| 428 |
+
if cols >= 2:
|
| 429 |
+
new_row[1] = "Beginning Balance"
|
| 430 |
+
if cols >= 1:
|
| 431 |
+
new_row[0] = "" # date empty
|
| 432 |
+
# Put the starting balance in the last column if it exists (balance column)
|
| 433 |
+
new_row[-1] = start_balance
|
| 434 |
+
# Insert after header
|
| 435 |
+
grid.insert(1, new_row)
|
| 436 |
|
| 437 |
+
return grid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
|
|
|
|
|
|
| 439 |
|
| 440 |
+
def normalize_html_tables(text: str) -> str:
|
| 441 |
+
"""
|
| 442 |
+
For every <table>...</table>:
|
| 443 |
+
- parse to grid (expanding colspan/rowspan)
|
| 444 |
+
- normalize transaction headers (remove glued beginning balance artifacts)
|
| 445 |
+
- insert missing 'Beginning Balance' row when it was embedded in header
|
| 446 |
+
- emit normalized <table> HTML without colspan/rowspan
|
| 447 |
+
"""
|
| 448 |
+
if not text or "<table" not in text.lower():
|
| 449 |
+
return text
|
| 450 |
|
| 451 |
+
pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 452 |
+
out = []
|
| 453 |
+
last = 0
|
| 454 |
|
| 455 |
+
for m in pattern.finditer(text):
|
| 456 |
+
out.append(text[last : m.start()])
|
| 457 |
+
table_html = m.group(0)
|
| 458 |
+
try:
|
| 459 |
+
p = TableGridParser()
|
| 460 |
+
p.feed(table_html)
|
| 461 |
+
grid = _build_grid(p.rows)
|
| 462 |
+
grid = _normalize_transaction_header_and_beginning_balance(grid)
|
| 463 |
+
out.append(_grid_to_html(grid) if grid else table_html)
|
| 464 |
+
except Exception:
|
| 465 |
+
out.append(table_html)
|
| 466 |
+
last = m.end()
|
| 467 |
|
| 468 |
+
out.append(text[last:])
|
| 469 |
+
return "".join(out)
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
|
| 472 |
def stabilize_tables_and_text(page_md: str) -> str:
|
|
|
|
| 473 |
if not page_md:
|
| 474 |
return page_md
|
| 475 |
+
|
| 476 |
page_md = normalize_money_glyphs(page_md)
|
| 477 |
|
| 478 |
blocks = re.split(r"\n\s*\n", page_md.strip())
|
|
|
|
| 485 |
|
| 486 |
stabilized = "\n\n".join(out_blocks)
|
| 487 |
|
| 488 |
+
# Normalize all HTML tables (generic): expand colspan/rowspan + fix header artifacts + re-add beginning balance row when needed
|
| 489 |
+
stabilized = normalize_html_tables(stabilized)
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
+
# Close tags to prevent bleed
|
| 492 |
return close_unclosed_html(stabilized)
|
| 493 |
|
| 494 |
|
|
|
|
| 588 |
he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
|
| 589 |
fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
|
| 590 |
|
|
|
|
| 591 |
he = max(0.02, min(0.25, he))
|
| 592 |
fs = max(0.75, min(0.98, fs))
|
| 593 |
|
| 594 |
parts = []
|
| 595 |
|
|
|
|
| 596 |
hdr = ""
|
| 597 |
if is_pdf:
|
| 598 |
hdr = extract_zone_text_pdf(path, page_num, 0, he)
|
|
|
|
| 603 |
if hdr and hdr.strip():
|
| 604 |
parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
|
| 605 |
|
|
|
|
| 606 |
if page_md and page_md.strip():
|
| 607 |
parts.append(stabilize_tables_and_text(page_md.strip()))
|
| 608 |
|
|
|
|
| 609 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 610 |
ftr = ""
|
| 611 |
if is_pdf:
|
|
|
|
| 628 |
return f"Error: {e}\n\n{traceback.format_exc()}"
|
| 629 |
|
| 630 |
finally:
|
|
|
|
| 631 |
for p in page_images:
|
| 632 |
try:
|
| 633 |
if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
|