rehan953 commited on
Commit
3d43af7
Β·
verified Β·
1 Parent(s): dc0508c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +157 -29
app.py CHANGED
@@ -1,9 +1,7 @@
1
  """
2
  GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion
3
  and table-structure stabilization for downstream bank-statement pipelines.
4
-
5
  Hard-coded knobs (no environment variables required).
6
-
7
  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)
@@ -11,8 +9,9 @@ Primary goals for reconcile rate:
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
@@ -52,7 +51,6 @@ FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
52
  # HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
53
  # ============================================================
54
 
55
- # IMPORTANT: Do NOT hard-code secrets in a public Space.
56
  GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
57
 
58
  # Higher = better OCR for small/right-aligned digits; slower
@@ -423,7 +421,6 @@ def _drop_truly_empty_columns(grid):
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
 
@@ -480,7 +477,6 @@ def _merge_blank_header_text_columns(grid):
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]
@@ -501,41 +497,164 @@ def _merge_blank_header_text_columns(grid):
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():
@@ -554,7 +673,16 @@ def normalize_html_tables(text: str) -> str:
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:
560
  out.append(table_html)
 
1
  """
2
  GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion
3
  and table-structure stabilization for downstream bank-statement pipelines.
 
4
  Hard-coded knobs (no environment variables required).
 
5
  Primary goals for reconcile rate:
6
  - preserve right-most columns (often "Balance") by higher DPI render + right padding
7
  - keep tables as tables (convert markdown pipe tables -> HTML table)
 
9
  - normalize HTML tables generically so downstream parsing/classification is stable:
10
  1) expand colspan/rowspan into a rectangular grid
11
  2) drop truly-empty columns (common in summary tables)
12
+ 3) merge "blank header" columns that contain text into the left column (common when DESCRIPTION is split)
13
  4) clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
14
+ 5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
15
  """
16
 
17
  # 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
  # Higher = better OCR for small/right-aligned digits; slower
 
421
  total += 1
422
  if str(row[i] or "").strip():
423
  non_empty += 1
 
424
  if total > 0 and (non_empty / total) <= 0.05:
425
  keep[i] = False
426
 
 
477
  non_empty_ratio = non_empty / total
478
  texty_ratio = (texty / non_empty) if non_empty else 0.0
479
 
 
480
  if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
481
  for r in range(1, len(grid)):
482
  row = grid[r]
 
497
  return new_grid
498
 
499
 
500
+ # --------------------------
501
+ # Generic fused-header recovery
502
+ # --------------------------
503
+
504
+ # Matches a standalone money value: optional currency symbol, optional sign,
505
+ # digits with optional comma grouping, optional decimal cents.
506
+ # Covers: $3,447.10 -1,234.56 $-500.00 1000 etc.
507
+ _MONEY_RE = re.compile(
508
+ r"^[\$£€Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
509
+ )
510
+
511
+ # All column-header keywords we recognise across any bank statement format.
512
+ # Each entry is a raw regex fragment (no anchors) used to detect the keyword
513
+ # at the START of a header cell.
514
+ _HEADER_KW_PATTERNS = [
515
+ r"DATE",
516
+ r"POSTING\s+DATE",
517
+ r"VALUE\s+DATE",
518
+ r"TRANSACTION(?:\s+(?:ID|DATE|TYPE|NO|NUMBER))?",
519
+ r"DESCRIPTION",
520
+ r"DETAILS?",
521
+ r"PARTICULARS?",
522
+ r"NARRATION",
523
+ r"REMARKS?",
524
+ r"REF(?:ERENCE)?(?:\s*(?:NO|NUM|NUMBER))?",
525
+ r"CHEQUE(?:\s*(?:NO|NUMBER))?",
526
+ r"AMOUNT",
527
+ r"DEBIT",
528
+ r"CREDIT",
529
+ r"DEPOSIT",
530
+ r"WITHDRAWAL",
531
+ r"BALANCE",
532
+ r"RUNNING\s+BALANCE",
533
+ r"AVAILABLE\s+BALANCE",
534
+ r"LEDGER\s+BALANCE",
535
+ ]
536
+
537
+ # Pre-compile: each pattern anchored at start, case-insensitive
538
+ _HEADER_KW_RES = [
539
+ re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
540
+ for p in _HEADER_KW_PATTERNS
541
+ ]
542
+
543
+ # A set version for quick "is this entire string a known keyword?" check
544
+ _HEADER_KW_EXACT_RE = re.compile(
545
+ r"^(?:" + r"|".join(_HEADER_KW_PATTERNS) + r")$",
546
+ re.IGNORECASE,
547
+ )
548
+
549
+
550
+ def _split_keyword_remainder(cell_text: str):
551
  """
552
+ If cell_text starts with a known header keyword followed by extra content,
553
+ return (keyword, remainder). Otherwise return (cell_text, "").
 
554
  """
555
+ s = cell_text.strip()
556
+ for pattern in _HEADER_KW_RES:
557
+ m = pattern.match(s)
558
+ if m:
559
+ keyword = m.group(1).strip()
560
+ remainder = m.group(2).strip()
561
+ return keyword, remainder
562
+ return s, ""
563
+
564
+
565
+ def _extract_fused_header_artifacts(header_row):
566
+ """
567
+ Generic detector for the OCR artifact where the first data row of a table
568
+ gets fused into the header cells during OCR.
569
+
570
+ The artifact pattern:
571
+ - At least one header cell contains a known column keyword PLUS free text
572
+ that is NOT itself a keyword β†’ "text-fused" column
573
+ e.g. "DESCRIPTIONBeginning Balance"
574
+ "NARRATIONOpening Balance"
575
+ "DETAILSForward Balance"
576
+ - At least one OTHER header cell contains a known column keyword PLUS a
577
+ money amount β†’ "money-fused" column
578
+ e.g. "BALANCE$3,447.10"
579
+ "AMOUNT 5,000.00"
580
+
581
+ Both signals must fire simultaneously (two-signal guard). If only one
582
+ fires the header is left completely unchanged and None is returned β€”
583
+ this prevents false positives on clean PDFs from any bank.
584
+
585
+ Returns:
586
+ (cleaned_header : list[str], recovered_row : list[str] | None)
587
+ """
588
+ if not header_row:
589
+ return list(header_row), None
590
+
591
+ ncols = len(header_row)
592
+ cleaned = list(header_row)
593
+ recovered = [""] * ncols
594
 
595
+ text_fused = [] # column indices where a descriptive label was fused
596
+ money_fused = [] # column indices where a money amount was fused
597
 
598
+ for idx, cell in enumerate(header_row):
599
+ cell_s = str(cell or "").strip()
600
+ if not cell_s:
 
601
  continue
602
 
603
+ keyword, remainder = _split_keyword_remainder(cell_s)
 
 
 
604
 
605
+ if not remainder:
606
+ # Nothing fused into this cell β€” leave it alone.
607
+ continue
608
+
609
+ # Determine what kind of remainder this is.
610
+ remainder_no_space = remainder.replace(" ", "")
611
+
612
+ if _MONEY_RE.match(remainder_no_space):
613
+ # Money amount fused into this header cell.
614
+ cleaned[idx] = keyword
615
+ recovered[idx] = remainder
616
+ money_fused.append(idx)
617
+
618
+ elif not _HEADER_KW_EXACT_RE.match(remainder.split()[0] if remainder.split() else ""):
619
+ # Free descriptive text fused into this header cell.
620
+ # (We exclude the case where the remainder is itself a keyword,
621
+ # which would just be a two-word column name like "POSTING DATE"
622
+ # partially matched β€” those are handled by the longer patterns.)
623
+ cleaned[idx] = keyword
624
+ recovered[idx] = remainder
625
+ text_fused.append(idx)
626
+
627
+ # Two-signal guard: only inject when BOTH kinds of fusion are present.
628
+ if text_fused and money_fused:
629
+ return cleaned, recovered
630
+
631
+ # One or zero signals β€” do not touch anything.
632
+ return list(header_row), None
633
+
634
+
635
+ def _clean_header_artifacts(grid):
636
+ """
637
+ Entry point called from normalize_html_tables.
638
+ Applies _extract_fused_header_artifacts to the header row of the grid.
639
+
640
+ Returns (grid, recovered_row | None).
641
+ The caller inserts recovered_row at position [1] when it is not None.
642
+ """
643
+ if not grid or not grid[0]:
644
+ return grid, None
645
+
646
+ cleaned_header, recovered_row = _extract_fused_header_artifacts(grid[0])
647
+ grid[0] = cleaned_header
648
+ return grid, recovered_row
649
 
650
 
651
  def normalize_html_tables(text: str) -> str:
652
  """
653
  For every <table>...</table>:
654
  - parse to grid (expand colspan/rowspan)
655
+ - drop truly-empty columns
656
+ - merge blank-header text columns into the left column
657
+ - clean header artifacts and recover fused first data row (generic)
658
  - emit normalized <table> HTML
659
  """
660
  if not text or "<table" not in text.lower():
 
673
  grid = _build_grid(p.rows)
674
  grid = _drop_truly_empty_columns(grid)
675
  grid = _merge_blank_header_text_columns(grid)
676
+
677
+ # Generic fused-header artifact recovery.
678
+ # recovered_row is only non-None when the two-signal guard passes
679
+ # (both a text-fused cell and a money-fused cell were detected).
680
+ # On all clean PDFs this returns None β†’ no side effects.
681
+ grid, recovered_row = _clean_header_artifacts(grid)
682
+
683
+ if recovered_row is not None:
684
+ grid.insert(1, recovered_row)
685
+
686
  out.append(_grid_to_html(grid) if grid else table_html)
687
  except Exception:
688
  out.append(table_html)