Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -13,6 +13,8 @@ Primary goals for reconcile rate:
|
|
| 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 |
6) promote misplaced header row: when real column headers land in a data row, restructure the grid
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
@@ -68,7 +70,7 @@ ENABLE_CONTRAST = True
|
|
| 68 |
DEFAULT_ZONE_FRAC = 0.12
|
| 69 |
PDF_HEADER_BAND_FRAC = 0.10
|
| 70 |
|
| 71 |
-
ENABLE_FOOTER_OCR =
|
| 72 |
PDF_FOOTER_BAND_FRAC = 0.88
|
| 73 |
|
| 74 |
MIN_CROP_HEIGHT = 112
|
|
@@ -572,6 +574,7 @@ _HEADER_KW_PATTERNS = [
|
|
| 572 |
r"OPENING\s+BAL\.?",
|
| 573 |
]
|
| 574 |
|
|
|
|
| 575 |
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 576 |
_HEADER_KW_RES = [
|
| 577 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
|
@@ -823,6 +826,100 @@ def _promote_misplaced_header_row(grid):
|
|
| 823 |
return new_grid
|
| 824 |
|
| 825 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
# --------------------------
|
| 827 |
# Normalizer entry point
|
| 828 |
# --------------------------
|
|
@@ -863,6 +960,9 @@ def normalize_html_tables(text: str) -> str:
|
|
| 863 |
# Fix 2: misplaced header row promotion (keyword-score guard β safe on clean PDFs)
|
| 864 |
grid = _promote_misplaced_header_row(grid)
|
| 865 |
|
|
|
|
|
|
|
|
|
|
| 866 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 867 |
except Exception:
|
| 868 |
out.append(table_html)
|
|
@@ -1007,7 +1107,12 @@ def run_ocr(uploaded_file):
|
|
| 1007 |
if page_md and page_md.strip():
|
| 1008 |
parts.append(stabilize_tables_and_text(page_md.strip()))
|
| 1009 |
|
| 1010 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 1012 |
ftr = ""
|
| 1013 |
if is_pdf:
|
|
@@ -1017,7 +1122,18 @@ def run_ocr(uploaded_file):
|
|
| 1017 |
if not (ftr and ftr.strip()):
|
| 1018 |
ftr = ocr_zone(page_images[page_num], fs, 1.0)
|
| 1019 |
if ftr and ftr.strip():
|
| 1020 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
|
| 1022 |
if parts:
|
| 1023 |
all_pages.append("\n\n".join(parts))
|
|
@@ -1046,4 +1162,4 @@ with gr.Blocks(title="GLM-OCR") as demo:
|
|
| 1046 |
run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
|
| 1047 |
|
| 1048 |
if __name__ == "__main__":
|
| 1049 |
-
demo.launch()
|
|
|
|
| 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 |
6) promote misplaced header row: when real column headers land in a data row, restructure the grid
|
| 16 |
+
7) fix fused key-value rows in summary sections (e.g. Interest Summary) appended to transaction tables
|
| 17 |
+
- footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it)
|
| 18 |
"""
|
| 19 |
|
| 20 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
|
|
|
| 70 |
DEFAULT_ZONE_FRAC = 0.12
|
| 71 |
PDF_HEADER_BAND_FRAC = 0.10
|
| 72 |
|
| 73 |
+
ENABLE_FOOTER_OCR = True
|
| 74 |
PDF_FOOTER_BAND_FRAC = 0.88
|
| 75 |
|
| 76 |
MIN_CROP_HEIGHT = 112
|
|
|
|
| 574 |
r"OPENING\s+BAL\.?",
|
| 575 |
]
|
| 576 |
|
| 577 |
+
|
| 578 |
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 579 |
_HEADER_KW_RES = [
|
| 580 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
|
|
|
| 826 |
return new_grid
|
| 827 |
|
| 828 |
|
| 829 |
+
|
| 830 |
+
# --------------------------
|
| 831 |
+
# Fix 3: Fused key-value rows in summary sections
|
| 832 |
+
# --------------------------
|
| 833 |
+
|
| 834 |
+
# Matches a value suffix that looks like a number or percentage fused onto the
|
| 835 |
+
# end of a label string. Examples:
|
| 836 |
+
# "Beginning Interest Rate0.00%" β label="Beginning Interest Rate" value="0.00%"
|
| 837 |
+
# "Number of days in this Period31" β label="Number of days in this Period" value="31"
|
| 838 |
+
# "Interest Earned this Period0.00" β label="Interest Earned this Period" value="0.00"
|
| 839 |
+
# Pattern: label text (non-digit) followed by a numeric/percentage token at the end.
|
| 840 |
+
_FUSED_KV_RE = re.compile(
|
| 841 |
+
r"^(.+?)\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d+)?%?)$",
|
| 842 |
+
re.DOTALL,
|
| 843 |
+
)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def _fix_fused_keyvalue_rows(grid):
|
| 847 |
+
"""
|
| 848 |
+
Generic fix for rows where a label and its value are fused into the first
|
| 849 |
+
cell while all other cells in that row are empty.
|
| 850 |
+
|
| 851 |
+
This happens when a bank statement appends a summary section (e.g. Interest
|
| 852 |
+
Summary, Fee Summary) after the main transaction table without a clear
|
| 853 |
+
column separator. The OCR wraps it inside the same table and each line
|
| 854 |
+
becomes a single-cell row like:
|
| 855 |
+
|
| 856 |
+
<td>Beginning Interest Rate0.00%</td> <td></td> <td></td> ...
|
| 857 |
+
|
| 858 |
+
The fix:
|
| 859 |
+
- For every data row where exactly the first cell is non-empty and all
|
| 860 |
+
other cells are empty, AND the first cell ends with a numeric/percentage
|
| 861 |
+
token fused directly onto text (no space separator), split it into
|
| 862 |
+
label (col 0) and value (last column of the table).
|
| 863 |
+
- If the cell already has a natural space before the value it is NOT
|
| 864 |
+
fused β leave it alone. This prevents touching normal description rows
|
| 865 |
+
that happen to end with a number.
|
| 866 |
+
|
| 867 |
+
Guard conditions (no-op if not met β safe for all other tables):
|
| 868 |
+
- The row must have ALL cells empty except the first.
|
| 869 |
+
- The first cell must match the fused pattern (text immediately followed
|
| 870 |
+
by a digit with no whitespace gap before the digit run).
|
| 871 |
+
- The match must produce a non-empty label AND a non-empty value.
|
| 872 |
+
"""
|
| 873 |
+
if not grid or len(grid) < 2:
|
| 874 |
+
return grid
|
| 875 |
+
|
| 876 |
+
ncols = len(grid[0])
|
| 877 |
+
new_grid = [grid[0]] # keep header unchanged
|
| 878 |
+
|
| 879 |
+
for row in grid[1:]:
|
| 880 |
+
# Guard: only process rows where first cell is non-empty and rest are empty
|
| 881 |
+
cells = [str(c or "").strip() for c in row]
|
| 882 |
+
if not cells:
|
| 883 |
+
new_grid.append(row)
|
| 884 |
+
continue
|
| 885 |
+
|
| 886 |
+
first = cells[0]
|
| 887 |
+
rest_empty = all(c == "" for c in cells[1:])
|
| 888 |
+
|
| 889 |
+
if not rest_empty or not first:
|
| 890 |
+
new_grid.append(row)
|
| 891 |
+
continue
|
| 892 |
+
|
| 893 |
+
# Guard: fused pattern requires NO whitespace immediately before the
|
| 894 |
+
# numeric suffix β "Rate0.00%" is fused, "Rate 0.00%" is not.
|
| 895 |
+
# We check by requiring the character just before the digit run is NOT
|
| 896 |
+
# a space.
|
| 897 |
+
fused_match = re.search(r"\S\d", first)
|
| 898 |
+
if not fused_match:
|
| 899 |
+
new_grid.append(row)
|
| 900 |
+
continue
|
| 901 |
+
|
| 902 |
+
m = _FUSED_KV_RE.match(first)
|
| 903 |
+
if not m:
|
| 904 |
+
new_grid.append(row)
|
| 905 |
+
continue
|
| 906 |
+
|
| 907 |
+
label = m.group(1).strip()
|
| 908 |
+
value = m.group(2).strip()
|
| 909 |
+
|
| 910 |
+
if not label or not value:
|
| 911 |
+
new_grid.append(row)
|
| 912 |
+
continue
|
| 913 |
+
|
| 914 |
+
# Build the fixed row: label in col 0, value in last col, rest empty
|
| 915 |
+
new_row = [""] * ncols
|
| 916 |
+
new_row[0] = label
|
| 917 |
+
new_row[ncols - 1] = value
|
| 918 |
+
new_grid.append(new_row)
|
| 919 |
+
|
| 920 |
+
return new_grid
|
| 921 |
+
|
| 922 |
+
|
| 923 |
# --------------------------
|
| 924 |
# Normalizer entry point
|
| 925 |
# --------------------------
|
|
|
|
| 960 |
# Fix 2: misplaced header row promotion (keyword-score guard β safe on clean PDFs)
|
| 961 |
grid = _promote_misplaced_header_row(grid)
|
| 962 |
|
| 963 |
+
# Fix 3: fused key-value rows in summary sections (no-space guard β safe on all PDFs)
|
| 964 |
+
grid = _fix_fused_keyvalue_rows(grid)
|
| 965 |
+
|
| 966 |
out.append(_grid_to_html(grid) if grid else table_html)
|
| 967 |
except Exception:
|
| 968 |
out.append(table_html)
|
|
|
|
| 1107 |
if page_md and page_md.strip():
|
| 1108 |
parts.append(stabilize_tables_and_text(page_md.strip()))
|
| 1109 |
|
| 1110 |
+
# Footer extraction.
|
| 1111 |
+
# Uses the same 3-step fallback as header: PDF text layer -> band words -> OCR crop.
|
| 1112 |
+
# Deduplication guard: if the footer text is already present in the main body OCR
|
| 1113 |
+
# output (accidental capture on some pages), we skip appending it to avoid
|
| 1114 |
+
# printing it twice. Comparison is done on a normalised lowercase snippet of the
|
| 1115 |
+
# first non-empty footer line so minor whitespace differences don't matter.
|
| 1116 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 1117 |
ftr = ""
|
| 1118 |
if is_pdf:
|
|
|
|
| 1122 |
if not (ftr and ftr.strip()):
|
| 1123 |
ftr = ocr_zone(page_images[page_num], fs, 1.0)
|
| 1124 |
if ftr and ftr.strip():
|
| 1125 |
+
ftr_clean = normalize_money_glyphs(ftr.strip())
|
| 1126 |
+
# Deduplication: check if the first meaningful line of the footer
|
| 1127 |
+
# already appears anywhere in the assembled page content so far.
|
| 1128 |
+
ftr_first_line = next(
|
| 1129 |
+
(ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()),
|
| 1130 |
+
""
|
| 1131 |
+
)
|
| 1132 |
+
already_present = ftr_first_line and any(
|
| 1133 |
+
ftr_first_line in part.lower() for part in parts
|
| 1134 |
+
)
|
| 1135 |
+
if not already_present:
|
| 1136 |
+
parts.append(ftr_clean)
|
| 1137 |
|
| 1138 |
if parts:
|
| 1139 |
all_pages.append("\n\n".join(parts))
|
|
|
|
| 1162 |
run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
|
| 1163 |
|
| 1164 |
if __name__ == "__main__":
|
| 1165 |
+
demo.launch()
|