rehan953 commited on
Commit
dc0508c
·
verified ·
1 Parent(s): 9d3abd8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +88 -75
app.py CHANGED
@@ -8,24 +8,22 @@ Primary goals for reconcile rate:
8
  - preserve right-most columns (often "Balance") by higher DPI render + right padding
9
  - keep tables as tables (convert markdown pipe tables -> HTML table)
10
  - return ---page-separator--- between pages
11
- - normalize HTML tables:
12
- - expand colspan/rowspan to a rectangular grid
13
- - merge “blank header” columns into DESCRIPTION when they contain text (common in many statements)
14
- - clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
15
- - DO NOT hardcode for any single bank/PDF
16
  """
17
 
18
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
19
  import asyncio
20
  try:
21
  _orig_close = asyncio.BaseEventLoop.close
22
-
23
  def _safe_close(self):
24
  try:
25
  _orig_close(self)
26
  except (ValueError, OSError):
27
  pass
28
-
29
  asyncio.BaseEventLoop.close = _safe_close
30
  except Exception:
31
  pass
@@ -86,11 +84,7 @@ def get_parser():
86
  global _parser
87
  if _parser is None:
88
  from glmocr import GlmOcr
89
-
90
- _parser = GlmOcr(
91
- api_key=GLMOCR_API_KEY,
92
- mode="maas",
93
- )
94
  return _parser
95
 
96
 
@@ -112,14 +106,10 @@ try:
112
  with open(FORMATTER_PATH, "r") as f:
113
  source = f.read()
114
  for label in (
115
- '"header"',
116
- "'header'",
117
- '"footer"',
118
- "'footer'",
119
- '"doc_header"',
120
- "'doc_header'",
121
- '"doc_footer"',
122
- "'doc_footer'",
123
  ):
124
  source = re.sub(r",\s*" + re.escape(label), "", source)
125
  source = re.sub(re.escape(label) + r"\s*,", "", source)
@@ -150,7 +140,6 @@ def get_header_footer_zones(regions, norm_height=1000):
150
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
151
  try:
152
  import pymupdf as fitz
153
-
154
  doc = fitz.open(pdf_path)
155
  page = doc[page_num]
156
  h, w = page.rect.height, page.rect.width
@@ -165,7 +154,6 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
165
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
166
  try:
167
  import pymupdf as fitz
168
-
169
  doc = fitz.open(pdf_path)
170
  page = doc[page_num]
171
  h = page.rect.height
@@ -188,7 +176,6 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
188
  zone_name = "header" if y_end_frac < 0.5 else "footer"
189
  try:
190
  from PIL import Image
191
-
192
  img = Image.open(image_path).convert("RGB")
193
  w, h = img.size
194
  y0 = max(0, int(h * y_start_frac))
@@ -242,7 +229,7 @@ def fix_account_number(hdr: str) -> str:
242
  if acct_match:
243
  acct = acct_match.group(1)
244
  if hdr.startswith(acct):
245
- hdr = hdr[len(acct) :].lstrip()
246
  return hdr
247
 
248
 
@@ -406,50 +393,55 @@ def _grid_to_html(grid):
406
  def _is_amount_like(s: str) -> bool:
407
  if not s:
408
  return False
409
- s2 = s.strip()
410
- return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s2) is not None
411
 
412
 
413
- def _clean_header_artifacts(grid):
414
  """
415
- Generic header text cleanup:
416
- - DESCRIPTIONBeginning Balance -> DESCRIPTION
417
- - DESCRIPTIONEnding Balance -> DESCRIPTION
418
- - BALANCE$3,447.10 -> BALANCE
419
  """
420
- if not grid or not grid[0]:
421
  return grid
422
- hdr = [str(c or "") for c in grid[0]]
423
 
424
- for idx, cell in enumerate(hdr):
425
- c = cell.strip()
 
 
426
 
427
- if re.search(r"\bDESCRIPTION\b", c, flags=re.IGNORECASE) and re.search(
428
- r"\b(Beginning|Ending)\s+Balance\b", c, flags=re.IGNORECASE
429
- ):
430
- hdr[idx] = "DESCRIPTION"
 
431
  continue
 
 
 
 
 
 
 
 
 
 
 
432
 
433
- if re.search(r"\bBALANCE\b", c, flags=re.IGNORECASE) and re.search(
434
- r"\$?\s*-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", c
435
- ):
436
- # If it contains a number glued to BALANCE, keep label only
437
- hdr[idx] = "BALANCE"
438
 
439
- grid[0] = hdr
440
- return grid
 
 
441
 
442
 
443
- def _collapse_blank_header_columns(grid):
444
  """
445
- Generic fix:
446
- If the header row has blank columns, and many body rows have non-empty *text* in that blank column,
447
  merge that column into the nearest non-empty header to the left (usually DESCRIPTION),
448
  then remove the blank column.
449
-
450
- This fixes:
451
- - transaction tables where DESCRIPTION is split into two columns (one header blank)
452
- - summary tables that have an extra blank trailing column
453
  """
454
  if not grid or len(grid) < 2:
455
  return grid
@@ -457,7 +449,6 @@ def _collapse_blank_header_columns(grid):
457
  header = [str(c or "").strip() for c in grid[0]]
458
  ncols = len(header)
459
  body = grid[1:]
460
-
461
  blank_cols = [i for i, h in enumerate(header) if h == ""]
462
  if not blank_cols:
463
  return grid
@@ -465,7 +456,6 @@ def _collapse_blank_header_columns(grid):
465
  keep = [True] * ncols
466
 
467
  for i in blank_cols:
468
- # merge target: nearest non-empty header to the left
469
  j = i - 1
470
  while j >= 0 and header[j] == "":
471
  j -= 1
@@ -475,7 +465,6 @@ def _collapse_blank_header_columns(grid):
475
  total = 0
476
  non_empty = 0
477
  texty = 0
478
-
479
  for row in body:
480
  if i >= len(row) or j >= len(row):
481
  continue
@@ -488,13 +477,12 @@ def _collapse_blank_header_columns(grid):
488
 
489
  if total == 0:
490
  continue
491
-
492
  non_empty_ratio = non_empty / total
493
- texty_ratio = (texty / non_empty) if non_empty > 0 else 0.0
494
 
495
- # Merge only when this column is mostly text continuation (not numbers)
496
  if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
497
- for r in range(1, len(grid)): # body only
498
  row = grid[r]
499
  if i >= len(row) or j >= len(row):
500
  continue
@@ -504,22 +492,51 @@ def _collapse_blank_header_columns(grid):
504
  row[j] = (left + " " + right).strip() if left else right
505
  keep[i] = False
506
 
507
- if not all(keep):
508
- new_grid = []
509
- for row in grid:
510
- new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
511
- return new_grid
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  return grid
514
 
515
 
516
  def normalize_html_tables(text: str) -> str:
517
  """
518
  For every <table>...</table>:
519
- - parse to grid (expanding colspan/rowspan)
520
- - merge blank header columns when they’re actually description continuation
 
521
  - clean common header artifacts
522
- - emit normalized <table> HTML without colspan/rowspan
523
  """
524
  if not text or "<table" not in text.lower():
525
  return text
@@ -535,7 +552,8 @@ def normalize_html_tables(text: str) -> str:
535
  p = TableGridParser()
536
  p.feed(table_html)
537
  grid = _build_grid(p.rows)
538
- grid = _collapse_blank_header_columns(grid)
 
539
  grid = _clean_header_artifacts(grid)
540
  out.append(_grid_to_html(grid) if grid else table_html)
541
  except Exception:
@@ -661,7 +679,6 @@ def run_ocr(uploaded_file):
661
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
662
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
663
 
664
- # clamp
665
  he = max(0.02, min(0.25, he))
666
  fs = max(0.75, min(0.98, fs))
667
 
@@ -701,7 +718,6 @@ def run_ocr(uploaded_file):
701
 
702
  except Exception as e:
703
  import traceback
704
-
705
  log.exception("run_ocr failed: %s", e)
706
  return f"Error: {e}\n\n{traceback.format_exc()}"
707
 
@@ -716,10 +732,7 @@ def run_ocr(uploaded_file):
716
 
717
  with gr.Blocks(title="GLM-OCR") as demo:
718
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
719
- file_in = gr.File(
720
- label="Upload PDF or image",
721
- file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
722
- )
723
  run_btn = gr.Button("Run OCR", variant="primary")
724
  out = gr.Textbox(lines=40, label="Output (markdown)")
725
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
 
8
  - preserve right-most columns (often "Balance") by higher DPI render + right padding
9
  - keep tables as tables (convert markdown pipe tables -> HTML table)
10
  - return ---page-separator--- between pages
11
+ - normalize HTML tables generically so downstream parsing/classification is stable:
12
+ 1) expand colspan/rowspan into a rectangular grid
13
+ 2) drop truly-empty columns (common in summary tables)
14
+ 3) merge “blank header columns that contain text into the left column (common when DESCRIPTION is split)
15
+ 4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
16
  """
17
 
18
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
19
  import asyncio
20
  try:
21
  _orig_close = asyncio.BaseEventLoop.close
 
22
  def _safe_close(self):
23
  try:
24
  _orig_close(self)
25
  except (ValueError, OSError):
26
  pass
 
27
  asyncio.BaseEventLoop.close = _safe_close
28
  except Exception:
29
  pass
 
84
  global _parser
85
  if _parser is None:
86
  from glmocr import GlmOcr
87
+ _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
 
 
 
 
88
  return _parser
89
 
90
 
 
106
  with open(FORMATTER_PATH, "r") as f:
107
  source = f.read()
108
  for label in (
109
+ '"header"', "'header'",
110
+ '"footer"', "'footer'",
111
+ '"doc_header"', "'doc_header'",
112
+ '"doc_footer"', "'doc_footer'",
 
 
 
 
113
  ):
114
  source = re.sub(r",\s*" + re.escape(label), "", source)
115
  source = re.sub(re.escape(label) + r"\s*,", "", source)
 
140
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
141
  try:
142
  import pymupdf as fitz
 
143
  doc = fitz.open(pdf_path)
144
  page = doc[page_num]
145
  h, w = page.rect.height, page.rect.width
 
154
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
155
  try:
156
  import pymupdf as fitz
 
157
  doc = fitz.open(pdf_path)
158
  page = doc[page_num]
159
  h = page.rect.height
 
176
  zone_name = "header" if y_end_frac < 0.5 else "footer"
177
  try:
178
  from PIL import Image
 
179
  img = Image.open(image_path).convert("RGB")
180
  w, h = img.size
181
  y0 = max(0, int(h * y_start_frac))
 
229
  if acct_match:
230
  acct = acct_match.group(1)
231
  if hdr.startswith(acct):
232
+ hdr = hdr[len(acct):].lstrip()
233
  return hdr
234
 
235
 
 
393
  def _is_amount_like(s: str) -> bool:
394
  if not s:
395
  return False
396
+ return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
 
397
 
398
 
399
+ def _drop_truly_empty_columns(grid):
400
  """
401
+ Drop columns that are empty in header AND almost always empty in body.
402
+ This fixes summary tables that have an extra blank trailing column.
 
 
403
  """
404
+ if not grid or len(grid) < 1:
405
  return grid
 
406
 
407
+ header = [str(c or "").strip() for c in grid[0]]
408
+ ncols = len(header)
409
+ if ncols <= 1:
410
+ return grid
411
 
412
+ body = grid[1:]
413
+ keep = [True] * ncols
414
+
415
+ for i in range(ncols):
416
+ if header[i] != "":
417
  continue
418
+ total = 0
419
+ non_empty = 0
420
+ for row in body:
421
+ if i >= len(row):
422
+ continue
423
+ total += 1
424
+ if str(row[i] or "").strip():
425
+ non_empty += 1
426
+ # keep column if it has meaningful content; otherwise drop
427
+ if total > 0 and (non_empty / total) <= 0.05:
428
+ keep[i] = False
429
 
430
+ if all(keep):
431
+ return grid
 
 
 
432
 
433
+ new_grid = []
434
+ for row in grid:
435
+ new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
436
+ return new_grid
437
 
438
 
439
+ def _merge_blank_header_text_columns(grid):
440
  """
441
+ If the header row has blank columns, and most body rows have non-empty *text* in that blank column,
 
442
  merge that column into the nearest non-empty header to the left (usually DESCRIPTION),
443
  then remove the blank column.
444
+ This fixes transaction tables where DESCRIPTION is split across two columns.
 
 
 
445
  """
446
  if not grid or len(grid) < 2:
447
  return grid
 
449
  header = [str(c or "").strip() for c in grid[0]]
450
  ncols = len(header)
451
  body = grid[1:]
 
452
  blank_cols = [i for i, h in enumerate(header) if h == ""]
453
  if not blank_cols:
454
  return grid
 
456
  keep = [True] * ncols
457
 
458
  for i in blank_cols:
 
459
  j = i - 1
460
  while j >= 0 and header[j] == "":
461
  j -= 1
 
465
  total = 0
466
  non_empty = 0
467
  texty = 0
 
468
  for row in body:
469
  if i >= len(row) or j >= len(row):
470
  continue
 
477
 
478
  if total == 0:
479
  continue
 
480
  non_empty_ratio = non_empty / total
481
+ texty_ratio = (texty / non_empty) if non_empty else 0.0
482
 
483
+ # Merge only when it behaves like description continuation text
484
  if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
485
+ for r in range(1, len(grid)):
486
  row = grid[r]
487
  if i >= len(row) or j >= len(row):
488
  continue
 
492
  row[j] = (left + " " + right).strip() if left else right
493
  keep[i] = False
494
 
495
+ if all(keep):
496
+ return grid
497
+
498
+ new_grid = []
499
+ for row in grid:
500
+ new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
501
+ return new_grid
502
+
503
+
504
+ def _clean_header_artifacts(grid):
505
+ """
506
+ Generic header cleanup:
507
+ - DESCRIPTIONBeginning Balance / DESCRIPTIONEnding Balance -> DESCRIPTION
508
+ - BALANCE$3,447.10 -> BALANCE
509
+ """
510
+ if not grid or not grid[0]:
511
+ return grid
512
+ hdr = [str(c or "").strip() for c in grid[0]]
513
 
514
+ for idx, cell in enumerate(hdr):
515
+ c = cell
516
+
517
+ if re.search(r"\bDESCRIPTION\b", c, flags=re.IGNORECASE) and re.search(
518
+ r"\b(Beginning|Ending)\s+Balance\b", c, flags=re.IGNORECASE
519
+ ):
520
+ hdr[idx] = "DESCRIPTION"
521
+ continue
522
+
523
+ if re.search(r"\bBALANCE\b", c, flags=re.IGNORECASE) and re.search(
524
+ r"\$?\s*-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", c
525
+ ):
526
+ hdr[idx] = "BALANCE"
527
+
528
+ grid[0] = hdr
529
  return grid
530
 
531
 
532
  def normalize_html_tables(text: str) -> str:
533
  """
534
  For every <table>...</table>:
535
+ - parse to grid (expand colspan/rowspan)
536
+ - drop truly-empty columns (summary tables extra blanks)
537
+ - merge blank-header text columns into the left column (DESCRIPTION split)
538
  - clean common header artifacts
539
+ - emit normalized <table> HTML
540
  """
541
  if not text or "<table" not in text.lower():
542
  return text
 
552
  p = TableGridParser()
553
  p.feed(table_html)
554
  grid = _build_grid(p.rows)
555
+ grid = _drop_truly_empty_columns(grid)
556
+ grid = _merge_blank_header_text_columns(grid)
557
  grid = _clean_header_artifacts(grid)
558
  out.append(_grid_to_html(grid) if grid else table_html)
559
  except Exception:
 
679
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
680
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
681
 
 
682
  he = max(0.02, min(0.25, he))
683
  fs = max(0.75, min(0.98, fs))
684
 
 
718
 
719
  except Exception as e:
720
  import traceback
 
721
  log.exception("run_ocr failed: %s", e)
722
  return f"Error: {e}\n\n{traceback.format_exc()}"
723
 
 
732
 
733
  with gr.Blocks(title="GLM-OCR") as demo:
734
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
735
+ file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
 
 
 
736
  run_btn = gr.Button("Run OCR", variant="primary")
737
  out = gr.Textbox(lines=40, label="Output (markdown)")
738
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)