glm-ocr-fixed / app.py
rehan953's picture
Update app.py
53b4e69 verified
Raw
History Blame
81.9 kB
#!/usr/bin/env python3
"""
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
Universal image pipeline (same for every PDF, no keywords / no bank logic):
- Higher rasterization scale + extra white padding so fine print, boxed
section labels, and right-aligned amounts sit farther from the clip edge.
- Mild contrast + unsharp mask on every raster sent to the model so
thin rules and small glyphs are easier to read before recognition.
Explicitly omitted vs the heavy Space build:
- No text-layer row injection, institution-specific splits (UCB / Navy /
TD / First Horizon / …), or doc-wide dedupe passes.
Included (data-driven, no institution names):
- HTML tables: modal logical width from rowspan-free rows (colspan-aware);
pad short rows; trim trailing empty cells; expand each row to a logical
grid, then slide a solitary amount token past trailing blank logical slots
into the rightmost slot (colspan-aware). Rowspan rows are skipped for edits
but do not disable an entire table. Stabilization runs in multiple passes.
- Split single cells that clearly contain transaction amount + trailing balance
(two money tokens, tight gap) into two cells so classifiers can see a balance column.
- Long runs of plain-text ledger lines (date + description + amount, optional balance)
are coerced into one HTML table when 5+ consecutive lines match generic patterns.
- thead uses th only; degenerate empty/sparse non-financial tables are dropped.
Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
pymupdf + pillow installed.
"""
# 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 tempfile
from collections import Counter
from typing import List, Optional, Tuple
import yaml
try:
import glmocr
GLMOCR_BASE = os.path.dirname(glmocr.__file__)
CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
except ImportError:
glmocr = None # type: ignore
GLMOCR_BASE = ""
CONFIG_PATH = ""
log = logging.getLogger("glmocr_simple_app")
logging.basicConfig(level=logging.INFO)
# ---------------------------------------------------------------------------
# Settings — tuned for dense financial PDFs; applies to every document
# ---------------------------------------------------------------------------
# Never commit secrets: Space / local runs use ZHIPU_API_KEY or GLMOCR_API_KEY.
GLMOCR_API_KEY = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito"
if not GLMOCR_API_KEY:
log.warning(
"No ZHIPU_API_KEY or GLMOCR_API_KEY in environment; GlmOcr() will fail until you set one."
)
# Rasterization: higher scale = more pixels per PDF point (helps small type,
# boxed headers, and narrow columns). Same constant for all uploads.
RENDER_SCALE = 3.35
# White margin as a fraction of page width/height after render. Extra right
# margin helps right-aligned currency columns that hug the page edge.
PAD_LEFT_FRAC = 0.035
PAD_RIGHT_FRAC = 0.125
PAD_TOP_FRAC = 0.018
PAD_BOTTOM_FRAC = 0.018
ENABLE_CONTRAST = True
# Slight contrast lift only; same factor for every file.
CONTRAST_FACTOR = 1.18
# Subtle edge enhancement after contrast (helps hairlines and small digits).
ENABLE_UNSHARP = True
UNSHARP_RADIUS = 0.78
UNSHARP_PERCENT = 76
UNSHARP_THRESHOLD = 1
DEFAULT_ZONE_FRAC = 0.12
PDF_HEADER_BAND_FRAC = 0.10
ENABLE_FOOTER_OCR = True
PDF_FOOTER_BAND_FRAC = 0.88
MIN_CROP_HEIGHT = 112
MIN_CROP_PIXELS = 112 * 112
# PNG compression 0–9; lower = less loss before GLM-OCR (same for all PDFs).
PAGE_PNG_COMPRESS_LEVEL = 3
# JPEG quality for small header/footer crops sent to the API.
ZONE_JPEG_QUALITY = 95
PAGE_JPEG_QUALITY = 88
MAX_PAGE_UPLOAD_BYTES = 9_200_000
# Keep page PNGs under MaaS input constraints to avoid 400 errors.
MAX_PAGE_LONG_SIDE = 3600
MAX_PAGE_TOTAL_PIXELS = 7_800_000
MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
_parser = None
def _enhance_raster_for_ocr(img):
"""
Improve legibility of every raster passed to GLM-OCR (full pages and
header/footer crops). No document text or keywords — same pipeline for
all PDFs and images.
"""
from PIL import ImageEnhance, ImageFilter
if ENABLE_CONTRAST:
img = ImageEnhance.Contrast(img).enhance(CONTRAST_FACTOR)
if ENABLE_UNSHARP:
img = img.filter(
ImageFilter.UnsharpMask(
radius=UNSHARP_RADIUS,
percent=UNSHARP_PERCENT,
threshold=UNSHARP_THRESHOLD,
)
)
return img
def get_parser():
global _parser
if glmocr is None:
raise RuntimeError("glmocr is not installed.")
if _parser is None:
from glmocr import GlmOcr
kw = {"mode": "maas"}
if GLMOCR_API_KEY:
kw["api_key"] = GLMOCR_API_KEY
_parser = GlmOcr(**kw)
return _parser
if CONFIG_PATH:
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
config.setdefault("pipeline", {}).setdefault("maas", {})
config["pipeline"]["maas"]["enabled"] = TrueCONFIG_PATH
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
except Exception:
pass
def get_header_footer_zones(regions, norm_height=1000):
if not regions:
return None, None
y_tops, y_bottoms = [], []
for r in regions:
bbox = r.get("bbox_2d") if isinstance(r, dict) else getattr(r, "bbox_2d", None)
if bbox and len(bbox) >= 4:
y_tops.append(bbox[1])
y_bottoms.append(bbox[3])
if not y_tops:
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
rect = fitz.Rect(0, h * y_start_frac, w, h * y_end_frac)
text = page.get_text(clip=rect).strip()
doc.close()
return text
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
y_lo = h * y_start_frac
y_hi = h * y_end_frac
words = page.get_text("words")
doc.close()
parts = []
for w in words:
if len(w) >= 5:
y0, y1 = float(w[1]), float(w[3])
if y0 < y_hi and y1 > y_lo:
parts.append(w[4])
return " ".join(parts).strip()
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))
y1 = min(h, int(h * y_end_frac))
if y1 <= y0:
return ""
crop = img.crop((0, y0, w, y1))
cw, ch = crop.size
if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS:
need_h = max(ch, MIN_CROP_HEIGHT)
need_w = max(cw, 1)
if (need_w * need_h) < MIN_CROP_PIXELS:
need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h)
canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255))
if zone_name == "header":
canvas.paste(crop, (0, 0))
else:
canvas.paste(crop, (0, need_h - ch))
crop = canvas
fd, path = tempfile.mkstemp(suffix=".jpg")
os.close(fd)
try:
crop.save(path, "JPEG", quality=ZONE_JPEG_QUALITY)
parser = get_parser()
out = parser.parse(path)
if not isinstance(out, list):
out = [out]
if out and getattr(out[0], "markdown_result", None):
return (out[0].markdown_result or "").strip()
finally:
try:
os.unlink(path)
except Exception:
pass
except Exception as e:
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
if "Account Number:" in hdr and "Account Number: " not in hdr:
m = re.search(r"[0-9]{5,}", hdr)
if m:
hdr = hdr.replace("Account Number:", "Account Number: " + m.group(0))
acct_match = re.search(r"Account Number: ([0-9]{5,})", hdr)
if acct_match:
acct = acct_match.group(1)
if hdr.startswith(acct):
hdr = hdr[len(acct) :].lstrip()
return hdr
def close_unclosed_html(md: str) -> str:
if not md:
return md
open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
close_tags = re.findall(r"</(table|tbody|thead|tr|td|th)>", md, flags=re.IGNORECASE)
def count(tags, name):
return sum(1 for t in tags if t.lower() == name)
for tag in reversed(["td", "th", "tr", "thead", "tbody", "table"]):
opened = count(open_tags, tag)
closed = count(close_tags, tag)
if opened > closed:
md += ("</%s>" % tag) * (opened - closed)
return md
_TR_OPEN = re.compile(r"<tr\b([^>]*)>", re.IGNORECASE)
_TR_CLOSE = re.compile(r"</tr>", re.IGNORECASE)
_CELL = re.compile(
r"<(td|th)(\b[^>]*?)>((?:(?!</?(?:td|th)\b).)*?)</(td|th)\s*>",
re.IGNORECASE | re.DOTALL,
)
# Currency tokens inside a cell (not anchored); used to split merged amount+balance.
_MONEY_IN_TEXT = re.compile(
r"(?:\$|€|£)?\s*-?\d{1,3}(?:,\d{3})*\.\d{2}\b|(?:\$|€|£)?\s*-?\d+\.\d{2}\b"
)
_EOL_MONEY = re.compile(
r"(?:\$|€|£)?\s*-?\d{1,3}(?:,\d{3})*\.\d{2}$|(?:\$|€|£)?\s*-?\d+\.\d{2}$"
)
def _split_cell_trailing_balance(full_cell: str) -> List[str]:
"""
When OCR puts transaction amount and running balance in one <td>, split into
two cells so classifiers can assign separate columns. Uses only currency
patterns and whitespace gaps (no header names).
"""
plain = _cell_plain_text(full_cell)
if len(plain) < 10:
return [full_cell]
spans = [(m.start(), m.end()) for m in _MONEY_IN_TEXT.finditer(plain)]
if len(spans) < 2:
return [full_cell]
(a0, a1), (b0, b1) = spans[-2], spans[-1]
if b1 < len(plain) - 16:
return [full_cell]
gap = plain[a1:b0]
if re.search(r"[A-Za-z]{2,}", gap):
return [full_cell]
if len(gap) > 14:
return [full_cell]
left = plain[:b0].strip()
right = plain[b0:].strip()
if not left or not right:
return [full_cell]
m = _CELL.fullmatch(full_cell.strip())
if not m:
return [full_cell]
tag, attrs = m.group(1), m.group(2)
return [
f"<{tag}{attrs}>{html.escape(left)}</{tag}>",
f"<{tag}{attrs}>{html.escape(right)}</{tag}>",
]
def _expand_tr_inner_split_merged(tr_inner: str) -> str:
"""Insert extra td/th where a single cell clearly holds amount + trailing balance."""
entries = _cell_entries(tr_inner)
if not entries:
return tr_inner
parts: List[str] = []
for full, span in entries:
if span != 1:
parts.append(full)
else:
parts.extend(_split_cell_trailing_balance(full))
return "".join(parts)
def _cell_entries(tr_inner: str) -> List[Tuple[str, int]]:
"""(full_cell_html, logical_width) for each td/th; 0 cells if unparseable."""
out: List[Tuple[str, int]] = []
for m in _CELL.finditer(tr_inner):
open_name, attrs, _body, close_name = m.group(1), m.group(2), m.group(3), m.group(4)
if open_name.lower() != close_name.lower():
continue
cm = re.search(r"colspan\s*=\s*[\"']?(\d+)", attrs, flags=re.IGNORECASE)
span = int(cm.group(1)) if cm else 1
span = max(1, span)
out.append((m.group(0), span))
return out
def _logical_row_width(entries: List[Tuple[str, int]]) -> int:
return sum(s for _f, s in entries)
def _cell_text_empty(full_cell: str) -> bool:
m = _CELL.fullmatch(full_cell.strip())
if not m:
inner = re.sub(r"<[^>]+>", " ", full_cell)
else:
inner = m.group(3)
inner = re.sub(r"\s+", " ", inner).strip()
inner = html.unescape(inner)
return inner == ""
def _cell_plain_text(full_cell: str) -> str:
"""Visible text of one td/th, no tags."""
m = _CELL.fullmatch(full_cell.strip())
if not m:
t = re.sub(r"<[^>]+>", " ", full_cell)
else:
t = m.group(3)
t = html.unescape(re.sub(r"\s+", " ", t).strip())
return t
def _is_whole_cell_currency(text: str) -> bool:
"""
True iff the cell is nothing but a currency-looking amount (optional $, commas, 2 decimals).
Excludes dates (slashes) and arbitrary prose — not keyed to column headers.
"""
t = (text or "").strip().strip("* \t\u00a0")
if not t or "/" in t:
return False
return bool(
re.fullmatch(
r"-?(?:\$|€|£)?\s*\d{1,3}(?:,\d{3})*\.\d{2}\s*",
t,
)
or re.fullmatch(r"-?(?:\$|€|£)?\s*\d+\.\d{2}\s*", t)
)
def _replace_cell_plain_body(full_cell: str, new_body_plain: str) -> str:
"""Rebuild one td/th preserving opening tag attributes; body is plain text (escaped)."""
m = _CELL.fullmatch(full_cell.strip())
if not m:
return full_cell
tag, attrs = m.group(1), m.group(2)
return f"<{tag}{attrs}>{html.escape(new_body_plain)}</{tag}>"
def _logical_plain_texts_from_entries(entries: List[Tuple[str, int]]) -> List[str]:
"""
Flatten one table row to one string per logical column: merged spans place
full visible text on the first slot only, remainder empty strings.
"""
w = sum(s for _, s in entries)
if w < 1:
return []
out = [""] * w
pos = 0
for full, span in entries:
span = max(1, span)
t = _cell_plain_text(full)
out[pos] = t
for k in range(1, span):
if pos + k < w:
out[pos + k] = ""
pos += span
return out
def _shift_rightmost_currency_with_blank_suffix(log: List[str]) -> List[str]:
"""
Find the rightmost logical slot that is currency-only and has only blank
slots to the end; move that amount into the rightmost slot. Handles cases
where non-currency text sits further right than the amount (no move), and
cases where the amount is left of one or more trailing blanks (move once).
"""
w = len(log)
if w < 2:
return log
j = -1
for i in range(w - 1, -1, -1):
t = (log[i] or "").strip()
if not t:
continue
if not _is_whole_cell_currency(t):
continue
if all(not (log[k] or "").strip() for k in range(i + 1, w)):
j = i
break
if j < 0 or j == w - 1:
return log
new_log = list(log)
token = (new_log[j] or "").strip()
new_log[j] = ""
new_log[w - 1] = token
return new_log
def _materialize_cells_from_logical(
entries: List[Tuple[str, int]], new_log: List[str]
) -> List[str]:
"""Rebuild physical td/th strings from a logical text row of length sum(span)."""
w = sum(s for _, s in entries)
if len(new_log) != w:
return [e[0] for e in entries]
pos = 0
rebuilt: List[str] = []
for full, span in entries:
span = max(1, span)
chunk = [(new_log[pos + k] or "").strip() for k in range(span)]
pos += span
body = " ".join(x for x in chunk if x).strip()
rebuilt.append(_replace_cell_plain_body(full, body))
return rebuilt
def _apply_row_amount_tail_shift(cells: List[str], spans: List[int]) -> List[str]:
"""Colspan-aware tail shift; repeat until stable (handles chained blanks)."""
if not cells or len(cells) != len(spans):
return cells
for _ in range(24):
entries = list(zip(cells, spans))
old_log = _logical_plain_texts_from_entries(entries)
if len(old_log) < 2:
break
new_log = _shift_rightmost_currency_with_blank_suffix(old_log)
if new_log == old_log:
break
cells = _materialize_cells_from_logical(entries, new_log)
return cells
def _infer_modal_logical_width(tr_inners: List[str]) -> int:
"""
Modal logical column count across rows (colspan sums). On frequency ties,
prefer the larger width so a rare short row is padded to the majority grid.
Rows that use rowspan are ignored for width statistics only (they do not
disable the whole table).
"""
widths: List[int] = []
for inner in tr_inners:
if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
continue
w = _logical_row_width(_cell_entries(inner))
if w > 0:
widths.append(w)
if not widths:
return -1
c = Counter(widths)
best = max(c.values())
candidates = [w for w, n in c.items() if n == best]
return max(candidates)
def _normalize_one_tr_inner(tr_inner: str, target: int) -> str:
entries = _cell_entries(tr_inner)
if not entries:
return tr_inner
cells = [e[0] for e in entries]
spans = [e[1] for e in entries]
w = sum(spans)
if w < target:
cells.extend(["<td></td>"] * (target - w))
spans.extend([1] * (target - w))
w = target
while w > target and cells:
if spans[-1] != 1 or not _cell_text_empty(cells[-1]):
break
w -= spans[-1]
cells.pop()
spans.pop()
if cells:
cells = _apply_row_amount_tail_shift(cells, spans)
return "".join(cells)
def normalize_html_table_row_widths(md: str) -> str:
"""
For each <table>, infer the dominant logical column count from rowspan-free
rows (colspan-aware), then pad rows that are too narrow or strip trailing
empty single-colspan cells from rows that are too wide.
No column names or fixed N: width comes from per-table row statistics.
Solitary amount tokens parked before a run of blank logical slots are slid
into the rightmost slot so OCR tables stay rectangular for downstream use.
Tables with rowspan are skipped. Non-currency text is not altered.
"""
if not md or "<table" not in md.lower():
return md
def repl_table(m: re.Match) -> str:
full = m.group(0)
low = full.lower()
inner_start = low.find(">") + 1
inner_end = low.rfind("</table>")
if inner_start <= 0 or inner_end < inner_start:
return full
prefix = full[:inner_start]
body = full[inner_start:inner_end]
suffix = full[inner_end:]
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", body, flags=re.IGNORECASE | re.DOTALL))
if not tr_blocks:
return full
# Phase 1: split merged amount+balance cells so column counts match real grids.
phase1_parts: List[str] = []
last_end = 0
for tm in tr_blocks:
phase1_parts.append(body[last_end : tm.start()])
seg = tm.group(0)
op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
cl = seg.lower().rfind("</tr>")
if not op or cl < 0:
phase1_parts.append(seg)
else:
open_tr = seg[: op.end()]
inner = seg[op.end() : cl]
close_tr = seg[cl:]
if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
phase1_parts.append(seg)
else:
phase1_parts.append(open_tr + _expand_tr_inner_split_merged(inner) + close_tr)
last_end = tm.end()
phase1_parts.append(body[last_end:])
body = "".join(phase1_parts)
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", body, flags=re.IGNORECASE | re.DOTALL))
tr_inners: List[str] = []
for tm in tr_blocks:
seg = tm.group(0)
op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
cl = seg.lower().rfind("</tr>")
if not op or cl < 0:
continue
tr_inners.append(seg[op.end() : cl])
target = _infer_modal_logical_width(tr_inners)
if target < 1:
return full
new_parts: List[str] = []
last_end = 0
for tm in tr_blocks:
new_parts.append(body[last_end : tm.start()])
seg = tm.group(0)
op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
cl = seg.lower().rfind("</tr>")
if not op or cl < 0:
new_parts.append(seg)
else:
open_tr = seg[: op.end()]
inner = seg[op.end() : cl]
close_tr = seg[cl:]
if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
new_parts.append(seg)
else:
new_parts.append(open_tr + _normalize_one_tr_inner(inner, target) + close_tr)
last_end = tm.end()
new_parts.append(body[last_end:])
return prefix + "".join(new_parts) + suffix
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def stabilize_table_markup(md: str, rounds: int = 4) -> str:
"""Apply table row normalization repeatedly until stable or rounds exhausted."""
cur = md
for _ in range(max(1, rounds)):
nxt = normalize_html_table_row_widths(cur)
if nxt == cur:
break
cur = nxt
return cur
_THEAD_BLOCK = re.compile(r"<thead\b[^>]*>.*?</thead>", re.IGNORECASE | re.DOTALL)
_CURRENCY_SNIFF = re.compile(r"[\$€£]")
def repair_thead_cell_semantics(md: str) -> str:
"""
Normalize header rows: cells inside <thead> should use <th>. Stray <td>
from OCR breaks rectangular header grids for parsers that expect <th> only
in thead. Institution-agnostic HTML repair only.
"""
if not md or "<thead" not in md.lower():
return md
def fix_block(m: re.Match) -> str:
block = m.group(0)
block = re.sub(r"<td(\b[^>]*?>)", r"<th\1", block, flags=re.IGNORECASE)
block = re.sub(r"</td\s*>", "</th>", block, flags=re.IGNORECASE)
return block
return _THEAD_BLOCK.sub(fix_block, md)
def _table_cell_plain_texts(full_table: str) -> List[str]:
return [_cell_plain_text(m.group(0)) for m in _CELL.finditer(full_table)]
def strip_degenerate_html_tables(md: str) -> str:
"""
Drop tables that are almost certainly non-ledger layout: all-empty grids,
or large sparse grids with no digits and no currency symbols (blank
worksheets / decorative boxes). Pattern-based only; no bank or product
names. Conservative thresholds to avoid removing real sparse tables.
"""
if not md or "<table" not in md.lower():
return md
def should_drop(full: str) -> bool:
texts = _table_cell_plain_texts(full)
n = len(texts)
if n < 1:
return False
nonempty = sum(1 for t in texts if t.strip())
if nonempty == 0:
return True
joined = " ".join(texts)
compact = re.sub(r"\s+", " ", joined).strip()
L = len(compact)
financial = bool(re.search(r"\d", joined)) or bool(_CURRENCY_SNIFF.search(joined))
if financial:
return False
if n >= 12 and nonempty <= max(2, int(n * 0.06)):
return True
if n >= 8 and nonempty <= 1 and L < 80:
return True
return False
def repl_table(m: re.Match) -> str:
return "" if should_drop(m.group(0)) else m.group(0)
out = re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
return re.sub(r"\n{3,}", "\n\n", out)
# OCR sometimes emits a malformed leading pseudo-header row like:
# Date05/09/25 | TypeDeposit | Amount2,270.00 | ...
_GLUED_HEADER_ROW_RE = re.compile(r"date\d{1,2}/\d{1,2}|typedeposit|amount\d", re.IGNORECASE)
def strip_malformed_leading_table_rows(md: str) -> str:
"""
Remove malformed leading rows in HTML tables when they are clearly bogus
header artifacts and the table already contains a proper header row below.
"""
if not md or "<table" not in md.lower():
return md
def repl_table(m: re.Match) -> str:
full = m.group(0)
trs = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
if len(trs) < 2:
return full
first = trs[0].group(0)
second = trs[1].group(0)
first_plain = _cell_plain_text(first).lower()
second_plain = _cell_plain_text(second).lower()
first_is_malformed = bool(_GLUED_HEADER_ROW_RE.search(first_plain))
second_looks_header = (
("date" in second_plain and "description" in second_plain)
or ("posting date" in second_plain and "description" in second_plain)
or ("date" in second_plain and "balance" in second_plain)
)
if not (first_is_malformed and second_looks_header):
return full
return full.replace(first, "", 1)
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def strip_subtotal_rows_from_transaction_tables(md: str) -> str:
"""
Remove subtotal/total line-item rows from transaction tables so aggregate
amounts are not mistaken as individual transactions.
"""
if not md or "<table" not in md.lower():
return md
def repl_table(m: re.Match) -> str:
full = m.group(0)
rows = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
if len(rows) < 2:
return full
# Some tables start with a title row, then the actual header appears in row 2/3.
sample_headers = [_cell_plain_text(r.group(0)).lower() for r in rows[:4]]
is_txn_like = any(
("date" in hp or "posting date" in hp)
and ("description" in hp)
and ("amount" in hp or "debit" in hp or "credit" in hp)
for hp in sample_headers
)
if not is_txn_like:
return full
out = full
for r in rows[1:]:
rp = _cell_plain_text(r.group(0)).lower()
if "subtotal" in rp or rp.startswith("total "):
out = out.replace(r.group(0), "", 1)
return out
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
# Match long digit runs even when OCR glues trailing letters (e.g. 9257403599CCD).
_LONG_NUM_TOKEN = re.compile(r"(?<!\d)\d{7,}(?!\d)")
def mask_non_monetary_long_numbers(md: str) -> str:
"""
Prevent long reference/trace/account IDs from being interpreted as monetary
values by downstream transaction extraction.
"""
if not md:
return md
def repl(m: re.Match) -> str:
tok = m.group(0)
# Keep common money-like tokens (handled elsewhere with decimal cents).
if re.match(r"^\d+\.\d{2}$", tok):
return tok
# Keep YYYYMMDD-like date compact tokens.
if len(tok) == 8 and tok.startswith(("19", "20")):
return tok
# Replace digits entirely so downstream cannot reinterpret ID tokens as money.
return "IDNUM"
return _LONG_NUM_TOKEN.sub(repl, md)
def directionalize_amount_headers(md: str) -> str:
"""
Convert ambiguous 'Amount' headers into 'Credit' or 'Debit' when table
context clearly indicates transaction direction (e.g. Deposits, Checks).
"""
if not md or "<table" not in md.lower():
return md
def map_header_token(label: str, ctx: str) -> str:
low = label.strip().lower()
if "amount" not in low:
return label
if re.search(r"\bcredit|deposit|deposits|incoming|received|interest earned|payment received\b", ctx):
return re.sub(r"amount", "Credit", label, flags=re.IGNORECASE)
if re.search(r"\bdebit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|payments\b", ctx):
return re.sub(r"amount", "Debit", label, flags=re.IGNORECASE)
return label
def repl_table(m: re.Match) -> str:
full = m.group(0)
# Table-local context from headers and first rows.
ctx = _cell_plain_text(full).lower()
changed = False
def repl_th(thm: re.Match) -> str:
nonlocal changed
tag = thm.group(0)
inner_m = re.search(r"<th\b[^>]*>(.*?)</th>", tag, flags=re.IGNORECASE | re.DOTALL)
if not inner_m:
return tag
inner = inner_m.group(1)
plain = _cell_plain_text(inner)
mapped = map_header_token(plain, ctx)
if mapped == plain:
return tag
changed = True
return re.sub(
r"(<th\b[^>]*>)(.*?)(</th>)",
rf"\1{html.escape(mapped)}\3",
tag,
count=1,
flags=re.IGNORECASE | re.DOTALL,
)
out = re.sub(r"<th\b[^>]*>.*?</th>", repl_th, full, flags=re.IGNORECASE | re.DOTALL)
if changed:
return out
# Fallback for OCR tables that put header row in <td>.
if "amount" in ctx and ("deposit" in ctx or "debit" in ctx or "withdraw" in ctx or "check" in ctx or "fee" in ctx):
out2 = re.sub(
r"(<td\b[^>]*>\s*)amount(\s*</td>)",
lambda mm: mm.group(1)
+ (
"Credit"
if ("deposit" in ctx or "credit" in ctx or "incoming" in ctx or "received" in ctx)
else "Debit"
)
+ mm.group(2),
full,
flags=re.IGNORECASE,
)
return out2
return full
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def directionalize_amount_headers_by_nearby_context(md: str) -> str:
"""
Use nearby narrative labels around each table (outside HTML cells) to set
Amount->Credit/Debit when the table itself is ambiguous.
"""
if not md or "<table" not in md.lower():
return md
credit_hint_re = re.compile(
r"\b(all\s+credit\s+activity|credit\s+activity|deposits?|credits?|money\s+in)\b",
re.IGNORECASE,
)
debit_hint_re = re.compile(
r"\b(all\s+debit\s+activity|debit\s+activity|withdrawals?|debits?|checks?|money\s+out)\b",
re.IGNORECASE,
)
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
out_parts: List[str] = []
last_end = 0
for tm in table_re.finditer(md):
out_parts.append(md[last_end:tm.start()])
tbl = tm.group(0)
left = re.sub(r"<[^>]+>", " ", md[max(0, tm.start() - 700): tm.start()])
right = re.sub(r"<[^>]+>", " ", md[tm.end(): min(len(md), tm.end() + 250)])
ctx = f"{left} {right}"
is_credit = bool(credit_hint_re.search(ctx))
is_debit = bool(debit_hint_re.search(ctx))
if is_credit ^ is_debit:
w = "Credit" if is_credit else "Debit"
tbl = re.sub(r"(<th\b[^>]*>\s*)amount(\s*</th>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
tbl = re.sub(r"(<td\b[^>]*>\s*)amount(\s*</td>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
out_parts.append(tbl)
last_end = tm.end()
out_parts.append(md[last_end:])
return "".join(out_parts)
def _strip_trailing_money_tokens(rest: str, max_take: int = 2) -> Tuple[str, List[str]]:
"""Strip 1–2 currency tokens from the right of *rest*; return (description, tokens oldest-first)."""
cur = rest.rstrip()
toks: List[str] = []
for _ in range(max_take):
cur = cur.rstrip()
m = _EOL_MONEY.search(cur)
if not m or m.end() != len(cur):
break
toks.append(m.group().strip())
cur = cur[: m.start()].rstrip()
toks.reverse()
return cur, toks
def _parse_ledger_line(line: str) -> Optional[Tuple[str, str, str, str]]:
"""
If *line* looks like a bank-style ledger row (date, description, amount, optional balance),
return (date, description, amount, balance_or_empty); else None.
"""
ln = line.strip()
if not ln or ln[0] in "#|!<":
return None
mm = re.match(
r"^(?P<d>(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|"
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}))"
r"\s+",
ln,
re.I,
)
if not mm:
return None
d = mm.group("d")
tail = ln[mm.end() :]
desc, toks = _strip_trailing_money_tokens(tail, 2)
desc = desc.strip()
if not toks or len(desc) < 2 or len(desc) > 420:
return None
amt = toks[0]
bal = toks[1] if len(toks) > 1 else ""
return (d, desc, amt, bal)
def _ledger_rows_to_html(rows: List[Tuple[str, str, str, str]]) -> str:
has_bal = any(r[3] for r in rows)
head = (
"<tr><th>Date</th><th>Description</th><th>Amount</th>"
+ ("<th>Balance</th>" if has_bal else "")
+ "</tr>"
)
body_lines = []
for d, desc, amt, bal in rows:
cells = [
f"<td>{html.escape(d)}</td>",
f"<td>{html.escape(desc)}</td>",
f"<td>{html.escape(amt)}</td>",
]
if has_bal:
cells.append(f"<td>{html.escape(bal)}</td>" if bal else "<td></td>")
body_lines.append("<tr>" + "".join(cells) + "</tr>")
return "<table>\n" + head + "\n" + "\n".join(body_lines) + "\n</table>"
def coalesce_loose_ledger_lines(text: str) -> str:
"""
When the model emits many consecutive plain-text ledger lines (no HTML table),
downstream classification sees no tables and skips. Convert long runs of
generic date + amount lines into a single HTML table. Requires 5+ matching
consecutive non-empty lines; does not match institution names.
"""
if not text or "<table" in text.lower():
return text
lines = text.split("\n")
out: List[str] = []
i = 0
while i < len(lines):
if not lines[i].strip():
out.append(lines[i])
i += 1
continue
run_rows: List[Tuple[str, str, str, str]] = []
j = i
while j < len(lines):
raw = lines[j]
if not raw.strip():
break
parsed = _parse_ledger_line(raw)
if parsed is None:
break
run_rows.append(parsed)
j += 1
if len(run_rows) >= 5:
if i > 0 and out and out[-1].strip():
out.append("")
out.append(_ledger_rows_to_html(run_rows))
i = j
continue
out.append(lines[i])
i += 1
return "\n".join(out)
def looks_like_markdown_table(block: str) -> bool:
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
if len(lines) < 2:
return False
if "|" not in lines[0]:
return False
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:
return block
def split_row(row: str):
row = row.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [p.strip() for p in row.split("|")]
header = split_row(lines[0])
body_lines = [ln for ln in lines[2:] if "|" in ln]
html_rows = []
html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
for ln in body_lines:
cols = split_row(ln)
if len(cols) < len(header):
cols += [""] * (len(header) - len(cols))
html_rows.append(
"<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>"
)
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
def normalize_money_glyphs(text: str) -> str:
if not text:
return text
t = text.replace("−", "-").replace("–", "-").replace("—", "-")
t = re.sub(
r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)",
r"-\1\2",
t,
)
def o_to_zero(m):
token = m.group(0)
return token.replace("O", "0").replace("o", "0")
t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
return 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:
out_blocks.append(coalesce_loose_ledger_lines(b))
merged = close_unclosed_html("\n\n".join(out_blocks))
merged = strip_malformed_leading_table_rows(merged)
merged = strip_subtotal_rows_from_transaction_tables(merged)
merged = mask_non_monetary_long_numbers(merged)
merged = repair_thead_cell_semantics(merged)
merged = stabilize_table_markup(merged, rounds=4)
merged = strip_degenerate_html_tables(merged)
return merged
def _parse_amount_or_none(s: str):
t = (s or "").strip()
if not t:
return None
t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
t = t.replace("−", "-").replace("–", "-").replace("—", "-")
if not re.search(r"\d", t):
return None
try:
return float(t)
except Exception:
return None
def _extract_rows_plain_from_table(full_table: str) -> List[List[str]]:
rows: List[List[str]] = []
for tr in re.finditer(r"<tr\b[^>]*>.*?</tr>", full_table, flags=re.IGNORECASE | re.DOTALL):
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr.group(0), flags=re.IGNORECASE | re.DOTALL)
if not inner_m:
continue
inner = inner_m.group(1)
cells = [_cell_plain_text(m.group(0)) for m in _CELL.finditer(inner)]
if cells:
rows.append(cells)
return rows
def _fmt_money(v: float) -> str:
return f"{v:,.2f}"
def _is_date_like(s: str) -> bool:
t = (s or "").strip()
return bool(re.match(r"^(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)$", t))
def normalize_ambiguous_amount_balance_tables(md: str) -> str:
"""
For transaction-like tables that expose only a single Amount column plus
Balance, convert to explicit Credit/Debit using generic running-balance
inference. This avoids sign ambiguity and improves reconcile stability.
"""
if not md or "<table" not in md.lower():
return md
credit_hint_re = re.compile(
r"\b(deposit|deposits|credit|credits|incoming|received|interest earned|refund|return)\b",
re.IGNORECASE,
)
debit_hint_re = re.compile(
r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|transfer out|purchase)\b",
re.IGNORECASE,
)
def repl_table(m: re.Match) -> str:
full = m.group(0)
rows = _extract_rows_plain_from_table(full)
if len(rows) < 3:
return full
hdr = [c.strip().lower() for c in rows[0]]
amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
balance_idxs = [i for i, c in enumerate(hdr) if "balance" in c]
has_credit = any("credit" in c for c in hdr)
has_debit = any("debit" in c for c in hdr)
date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
if has_credit or has_debit or not amount_idxs or not balance_idxs:
return full
amount_idx = amount_idxs[0]
balance_idx = balance_idxs[0]
date_idx = date_idxs[0] if date_idxs else 0
desc_idx = desc_idxs[0] if desc_idxs else min(1, max(0, len(rows[0]) - 1))
new_rows: List[List[str]] = [["Date", "Description", "Credit", "Debit", "Balance"]]
prev_bal = None
section_hint = ""
converted = 0
for r in rows[1:]:
if not r:
continue
padded = r + [""] * (max(amount_idx, balance_idx, date_idx, desc_idx) + 1 - len(r))
date_txt = (padded[date_idx] or "").strip()
desc_txt = (padded[desc_idx] or "").strip()
amt_txt = (padded[amount_idx] or "").strip()
bal_txt = (padded[balance_idx] or "").strip()
amt = _parse_amount_or_none(amt_txt)
bal = _parse_amount_or_none(bal_txt)
row_blob = " ".join((c or "").strip() for c in padded).strip()
if row_blob and not _is_date_like(date_txt) and amt is None:
section_hint = row_blob
continue
if amt is None and bal is None:
continue
direction = ""
# Primary: infer from balance delta between consecutive rows.
if amt is not None and bal is not None and prev_bal is not None:
delta = bal - prev_bal
tol = max(0.55, 0.025 * abs(amt))
if abs(abs(delta) - abs(amt)) <= tol:
direction = "credit" if delta > 0 else "debit"
# Secondary: section + description lexical hints.
ctx = f"{section_hint} {desc_txt}"
if not direction and credit_hint_re.search(ctx):
direction = "credit"
if not direction and debit_hint_re.search(ctx):
direction = "debit"
credit = ""
debit = ""
if amt is not None:
if direction == "credit":
credit = _fmt_money(abs(amt))
elif direction == "debit":
debit = _fmt_money(abs(amt))
else:
# Conservative fallback for ambiguous rows: preserve as debit
# to avoid overstating inflows.
debit = _fmt_money(abs(amt))
balance_out = _fmt_money(bal) if bal is not None else ""
new_rows.append([date_txt, desc_txt, credit, debit, balance_out])
if bal is not None:
prev_bal = bal
converted += 1
if converted < 5:
return full
html_rows = []
html_rows.append(
"<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"
)
for rr in new_rows[1:]:
html_rows.append(
"<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>"
)
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def normalize_amount_only_transaction_tables(md: str) -> str:
"""
Convert transaction-like tables with Date/Description/Amount (no Credit/Debit)
into explicit Credit/Debit columns using generic lexical heuristics.
"""
if not md or "<table" not in md.lower():
return md
credit_hint_re = re.compile(
r"\b(deposit|deposits|credit|credits|incoming|received|return|refund|interest|pmt cr)\b",
re.IGNORECASE,
)
debit_hint_re = re.compile(
r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|purchase|ach|bill)\b",
re.IGNORECASE,
)
def repl_table(m: re.Match) -> str:
full = m.group(0)
rows = _extract_rows_plain_from_table(full)
if len(rows) < 3:
return full
hdr = [c.strip().lower() for c in rows[0]]
if any("credit" in c for c in hdr) or any("debit" in c for c in hdr):
return full
amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
if not amount_idxs or not date_idxs:
return full
amount_idx = amount_idxs[0]
date_idx = date_idxs[0]
desc_idx = desc_idxs[0] if desc_idxs else min(1, len(rows[0]) - 1)
table_ctx = _cell_plain_text(full)
new_rows = [["Date", "Description", "Credit", "Debit"]]
converted = 0
for r in rows[1:]:
if not r:
continue
padded = r + [""] * (max(amount_idx, date_idx, desc_idx) + 1 - len(r))
date_txt = (padded[date_idx] or "").strip()
if not _is_date_like(date_txt):
continue
desc_txt = (padded[desc_idx] or "").strip()
amt = _parse_amount_or_none((padded[amount_idx] or "").strip())
if amt is None:
continue
ctx = f"{table_ctx} {desc_txt}"
credit = ""
debit = ""
if credit_hint_re.search(ctx) and not debit_hint_re.search(ctx):
credit = _fmt_money(abs(amt))
elif debit_hint_re.search(ctx) and not credit_hint_re.search(ctx):
debit = _fmt_money(abs(amt))
else:
# Conservative default for unknown direction.
debit = _fmt_money(abs(amt))
new_rows.append([date_txt, desc_txt, credit, debit])
converted += 1
if converted < 5:
return full
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"]
for rr in new_rows[1:]:
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def infer_credit_debit_from_balance_deltas(md: str) -> str:
"""
When OCR leaves Credit/Debit empty but a running Balance column is present,
infer signed movement from consecutive balance deltas (same for all banks).
"""
if not md or "<table" not in md.lower():
return md
def repl_table(m: re.Match) -> str:
full = m.group(0)
rows = _extract_rows_plain_from_table(full)
if len(rows) < 3:
return full
hdr = [c.strip() for c in rows[0]]
hdr_l = [h.lower() for h in hdr]
date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
desc_i = next(
(i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h),
None,
)
credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
if date_i is None or desc_i is None or credit_i is None or debit_i is None or balance_i is None:
return full
max_len = max(len(hdr), max((len(r) for r in rows), default=0))
body_bals: List[Optional[float]] = []
body_rows: List[List[str]] = []
for r in rows[1:]:
padded = (r + [""] * (max_len - len(r)))[:max_len]
dt = (padded[date_i] or "").strip()
if not _is_date_like(dt):
continue
cr = _parse_amount_or_none((padded[credit_i] or "").strip()) if credit_i < len(padded) else None
db = _parse_amount_or_none((padded[debit_i] or "").strip()) if debit_i < len(padded) else None
bal = _parse_amount_or_none((padded[balance_i] or "").strip()) if balance_i < len(padded) else None
body_rows.append(padded)
body_bals.append(bal)
if len(body_rows) < 3:
return full
empty_cd = 0
for i, pr in enumerate(body_rows):
cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
if cr is None and db is None:
empty_cd += 1
if empty_cd < max(3, int(0.6 * len(body_rows))):
return full
new_body: List[List[str]] = []
for i, pr in enumerate(body_rows):
row = pr[:]
cr0 = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
db0 = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
if cr0 is not None or db0 is not None:
new_body.append(row)
continue
if i == 0 or body_bals[i] is None:
new_body.append(row)
continue
prev_b = body_bals[i - 1]
cur_b = body_bals[i]
if prev_b is None or cur_b is None:
new_body.append(row)
continue
delta = cur_b - prev_b
if abs(delta) < 1e-6:
new_body.append(row)
continue
tol = max(0.55, 0.025 * abs(delta))
if abs(delta) <= tol:
new_body.append(row)
continue
if delta > 0:
row[credit_i] = _fmt_money(delta)
else:
row[debit_i] = _fmt_money(-delta)
new_body.append(row)
filled = 0
for i, pr in enumerate(new_body):
cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
if cr is not None or db is not None:
filled += 1
if filled < max(2, int(0.35 * len(new_body))):
return full
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
for pr in new_body:
cells = (pr + [""] * (len(hdr) - len(pr)))[: len(hdr)]
html_rows.append(
"<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in cells) + "</tr>"
)
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def sanitize_transaction_tables(md: str) -> str:
"""
Final defensive cleanup for transaction-like tables:
- remove in-body header repeats and subtotal rows
- deduplicate repeated rows
- correct likely credit/debit direction using row text and balance deltas
"""
if not md or "<table" not in md.lower():
return md
credit_kw = re.compile(
r"\b(deposit|deposits|credit|credits|recd|received|return|refund|interest|rtp\s*rcvd|pmt\s*cr)\b",
re.IGNORECASE,
)
debit_kw = re.compile(
r"\b(debit|debits|withdrawal|withdrawals|check|checks|payment|purchase|charge|fee|ach)\b",
re.IGNORECASE,
)
def parse_money(txt: str):
t = (txt or "").strip()
if not t:
return None
return _parse_amount_or_none(t)
def fmt_money(v: float) -> str:
return f"{abs(v):,.2f}"
def repl_table(m: re.Match) -> str:
full = m.group(0)
rows = _extract_rows_plain_from_table(full)
if len(rows) < 2:
return full
hdr = [c.strip() for c in rows[0]]
hdr_l = [h.lower() for h in hdr]
date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h), None)
balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
is_txn = (date_i is not None) and (desc_i is not None) and (
credit_i is not None or debit_i is not None or amount_i is not None
)
if not is_txn:
return full
# Without a real balance column, do not rewrite the table (avoids blank balances).
if balance_i is None:
return full
max_cols = max(len(hdr), max((len(r) for r in rows), default=len(hdr)))
if max_cols < 4:
max_cols = 4
new_hdr = hdr[:]
if amount_i is not None and credit_i is None and debit_i is None:
new_hdr[amount_i] = "Debit"
debit_i = amount_i
amount_i = None
if credit_i is None:
new_hdr.append("Credit")
credit_i = len(new_hdr) - 1
if debit_i is None:
new_hdr.append("Debit")
debit_i = len(new_hdr) - 1
records = []
seen = set()
for r in rows[1:]:
padded = r + [""] * (len(new_hdr) - len(r))
low = [c.strip().lower() for c in padded]
joined = " ".join(low)
# Remove duplicate header rows inside body and subtotal/total lines.
if any(
x in low
for x in ("date", "posting date", "description", "amount", "credit", "debit", "balance")
):
continue
if "subtotal" in joined or joined.startswith("total "):
continue
dt = padded[date_i].strip() if date_i is not None and date_i < len(padded) else ""
if not _is_date_like(dt):
continue
desc = padded[desc_i].strip() if desc_i is not None and desc_i < len(padded) else ""
bal = parse_money(padded[balance_i]) if balance_i < len(padded) else None
cr = parse_money(padded[credit_i]) if credit_i < len(padded) else None
db = parse_money(padded[debit_i]) if debit_i < len(padded) else None
if cr is None and db is None:
continue
amt = cr if cr is not None else db
direction = "credit" if cr is not None else "debit"
# Row-level lexical correction
if amt is not None:
if credit_kw.search(desc) and not debit_kw.search(desc):
direction = "credit"
elif debit_kw.search(desc) and not credit_kw.search(desc):
direction = "debit"
key = (dt, desc, f"{amt:.2f}" if isinstance(amt, (int, float)) else "", f"{bal:.2f}" if isinstance(bal, (int, float)) else "")
if key in seen:
continue
seen.add(key)
records.append(
{
"row": padded[: len(new_hdr)],
"amount": abs(amt) if amt is not None else None,
"balance": bal,
"lex_dir": direction,
}
)
if not records:
return full
# Choose balance interpretation direction (forward vs reverse row order)
# based on which one produces more valid delta-to-amount matches.
def delta_match(delta: float, amt_val: float) -> str:
tol = max(0.75, 0.03 * abs(amt_val))
if abs(delta - abs(amt_val)) <= tol:
return "credit"
if abs(delta + abs(amt_val)) <= tol:
return "debit"
return ""
fwd_matches = 0
rev_matches = 0
prev_bal = None
for rec in records:
bal = rec["balance"]
amt_val = rec["amount"]
if bal is None or amt_val is None:
continue
if prev_bal is not None:
if delta_match(bal - prev_bal, amt_val):
fwd_matches += 1
if delta_match(prev_bal - bal, amt_val):
rev_matches += 1
prev_bal = bal
use_reverse = rev_matches > fwd_matches
body = []
prev_bal = None
for rec in records:
out = rec["row"][:]
amt_val = rec["amount"]
bal = rec["balance"]
direction = rec["lex_dir"]
if amt_val is not None and bal is not None and prev_bal is not None:
delta = (prev_bal - bal) if use_reverse else (bal - prev_bal)
d = delta_match(delta, amt_val)
if d:
direction = d
if bal is not None:
prev_bal = bal
out[credit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "credit") else ""
out[debit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "debit") else ""
out[balance_i] = f"{bal:,.2f}" if bal is not None else out[balance_i]
body.append(out)
if not body:
return full
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_hdr) + "</tr>"]
for rr in body:
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
return re.sub(
r"<table\b[^>]*>.*?</table>",
repl_table,
md,
flags=re.IGNORECASE | re.DOTALL,
)
def _extract_daily_balance_by_date(md: str):
"""
Build a date->balance map from daily-balance style tables.
Supports compact statements with repeated Date/Balance column pairs.
"""
out = {}
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
t = tm.group(0)
rows = _extract_rows_plain_from_table(t)
if len(rows) < 2:
continue
hdr = [c.strip().lower() for c in rows[0]]
if not hdr:
continue
date_cols = [i for i, c in enumerate(hdr) if "date" in c]
bal_cols = [i for i, c in enumerate(hdr) if "balance" in c]
if not date_cols or not bal_cols:
continue
pairs = []
for di in date_cols:
bi = next((b for b in bal_cols if b > di), None)
if bi is not None:
pairs.append((di, bi))
if not pairs:
continue
for r in rows[1:]:
for di, bi in pairs:
if di >= len(r) or bi >= len(r):
continue
d = (r[di] or "").strip()
if not re.search(r"\b\d{1,2}/\d{1,2}(?:/\d{2,4})?\b", d):
continue
m = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d)
if not m:
continue
key = m.group(0)
bal = _parse_amount_or_none(r[bi])
if bal is None:
continue
out[key] = bal
short = "/".join(key.split("/")[:2])
out[short] = bal
return out
def enrich_transaction_tables_with_daily_balances(md: str) -> str:
"""
If transaction tables have Date+Amount but no Balance column, append a Balance
column using date-matched daily ending balances from the same document.
"""
if not md or "<table" not in md.lower():
return md
dmap = _extract_daily_balance_by_date(md)
if not dmap:
return md
def repl(m: re.Match) -> str:
table = m.group(0)
if re.search(r"(?:colspan|rowspan)\s*=", table, flags=re.IGNORECASE):
return table
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", table, flags=re.IGNORECASE | re.DOTALL))
if len(tr_blocks) < 2:
return table
first = tr_blocks[0].group(0)
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", first, flags=re.IGNORECASE | re.DOTALL)
if not h_inner_m:
return table
h_inner = h_inner_m.group(1)
h_cells = list(_CELL.finditer(h_inner))
if not h_cells:
return table
hdr_txt = [_cell_plain_text(c.group(0)).strip().lower() for c in h_cells]
if any("balance" in c for c in hdr_txt):
return table
if not any("date" in c for c in hdr_txt):
return table
if not any(("amount" in c) or ("debit" in c) or ("credit" in c) for c in hdr_txt):
return table
date_idx = next((i for i, c in enumerate(hdr_txt) if "date" in c), -1)
if date_idx < 0:
return table
new_parts: List[str] = []
cursor = 0
added = 0
for i, trm in enumerate(tr_blocks):
new_parts.append(table[cursor : trm.start()])
tr_seg = trm.group(0)
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
if not inner_m:
new_parts.append(tr_seg)
cursor = trm.end()
continue
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
tr_close = "</tr>"
inner = inner_m.group(1)
cells = [c.group(0) for c in _CELL.finditer(inner)]
if i == 0:
new_inner = inner + "<th>Balance</th>"
new_parts.append(tr_open + new_inner + tr_close)
else:
if date_idx >= len(cells):
new_parts.append(tr_seg)
cursor = trm.end()
continue
d_txt = _cell_plain_text(cells[date_idx])
dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or "")
bal = dmap.get(dm.group(0)) if dm else None
if bal is None and dm:
bal = dmap.get("/".join(dm.group(0).split("/")[:2]))
if bal is None:
new_parts.append(tr_seg)
cursor = trm.end()
continue
new_inner = inner + f"<td>{bal:,.2f}</td>"
new_parts.append(tr_open + new_inner + tr_close)
added += 1
cursor = trm.end()
new_parts.append(table[cursor:])
if added < 3:
return table
return "".join(new_parts)
return re.sub(r"<table\b[^>]*>.*?</table>", repl, md, flags=re.IGNORECASE | re.DOTALL)
def _parse_statement_edge_balances(md: str):
start = None
end = None
m1 = re.search(
r"(?:Beginning|Starting)\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
md,
flags=re.IGNORECASE,
)
if m1:
start = _parse_amount_or_none(m1.group(1))
m2 = re.search(
r"Ending\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
md,
flags=re.IGNORECASE,
)
if m2:
end = _parse_amount_or_none(m2.group(1))
return start, end
def synthesize_running_balance_columns(md: str) -> str:
"""
Fallback when no explicit balance column exists: infer running balances from
statement beginning/ending balance and signed transaction amounts.
"""
if not md or "<table" not in md.lower():
return md
start_bal, end_bal = _parse_statement_edge_balances(md)
if start_bal is None and end_bal is None:
return md
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
tr_re = re.compile(r"<tr\b[^>]*>.*?</tr>", re.IGNORECASE | re.DOTALL)
tables = list(table_re.finditer(md))
row_refs = []
amounts = []
headers = {}
for ti, tm in enumerate(tables):
t = tm.group(0)
if re.search(r"(?:colspan|rowspan)\s*=", t, flags=re.IGNORECASE):
continue
trs = list(tr_re.finditer(t))
if len(trs) < 2:
continue
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trs[0].group(0), flags=re.IGNORECASE | re.DOTALL)
if not h_inner_m:
continue
h_cells = [c.group(0) for c in _CELL.finditer(h_inner_m.group(1))]
if not h_cells:
continue
htxt = [_cell_plain_text(c).strip().lower() for c in h_cells]
if any("balance" in h for h in htxt):
continue
date_idx = next((i for i, h in enumerate(htxt) if "date" in h), -1)
amt_idx = next((i for i, h in enumerate(htxt) if "amount" in h or "debit" in h or "credit" in h), -1)
if date_idx < 0 or amt_idx < 0:
continue
headers[ti] = (date_idx, amt_idx)
for ri, trm in enumerate(trs[1:], 1):
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trm.group(0), flags=re.IGNORECASE | re.DOTALL)
if not inner_m:
continue
cells = [c.group(0) for c in _CELL.finditer(inner_m.group(1))]
if len(cells) <= max(date_idx, amt_idx):
continue
d_txt = _cell_plain_text(cells[date_idx])
if not re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or ""):
continue
amt = _parse_amount_or_none(_cell_plain_text(cells[amt_idx]))
if amt is None:
continue
row_refs.append((ti, ri))
amounts.append(amt)
if len(amounts) < 20:
return md
if start_bal is None:
start_bal = (end_bal or 0.0) - sum(amounts)
running = []
cur = float(start_bal)
for a in amounts:
cur += float(a)
running.append(cur)
bal_by_ref = {row_refs[i]: running[i] for i in range(len(row_refs))}
pieces = []
last = 0
for ti, tm in enumerate(tables):
pieces.append(md[last : tm.start()])
t = tm.group(0)
if ti not in headers:
pieces.append(t)
last = tm.end()
continue
date_idx, amt_idx = headers[ti]
trs = list(tr_re.finditer(t))
out_t = []
cur2 = 0
for idx, trm in enumerate(trs):
out_t.append(t[cur2 : trm.start()])
tr_seg = trm.group(0)
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
if not inner_m:
out_t.append(tr_seg)
cur2 = trm.end()
continue
inner = inner_m.group(1)
if idx == 0:
out_t.append(tr_open + inner + "<th>Balance</th></tr>")
else:
b = bal_by_ref.get((ti, idx))
if b is None:
out_t.append(tr_seg)
else:
out_t.append(tr_open + inner + f"<td>{b:,.2f}</td></tr>")
cur2 = trm.end()
out_t.append(t[cur2:])
pieces.append("".join(out_t))
last = tm.end()
pieces.append(md[last:])
return "".join(pieces)
def _extract_txn_date_balance_pairs(md: str):
pairs = []
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
t = tm.group(0)
rows = _extract_rows_plain_from_table(t)
if len(rows) < 2:
continue
hdr = [c.strip().lower() for c in rows[0]]
if not hdr:
continue
d_i = next((i for i, h in enumerate(hdr) if "date" in h), -1)
b_i = next((i for i, h in enumerate(hdr) if "balance" in h), -1)
a_i = next((i for i, h in enumerate(hdr) if "amount" in h or "debit" in h or "credit" in h), -1)
if d_i < 0 or b_i < 0 or a_i < 0:
continue
for r in rows[1:]:
if len(r) <= max(d_i, b_i):
continue
dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", (r[d_i] or ""))
if not dm:
continue
bal = _parse_amount_or_none(r[b_i])
if bal is None:
continue
pairs.append((dm.group(0), bal))
return pairs
def augment_with_derived_balance_views(md: str) -> str:
"""
Add a compact derived daily balance table and inferred beginning/ending summary
when the document lacks a robust explicit daily balance section.
"""
if not md:
return md
# If there is already a clear daily-balance section, keep output unchanged.
if re.search(r"daily\s+(?:ledger\s+)?balances", md, flags=re.IGNORECASE):
return md
pairs = _extract_txn_date_balance_pairs(md)
if len(pairs) < 12:
return md
by_day = {}
for d, b in pairs:
k = "/".join(d.split("/")[:2])
by_day[k] = b
if len(by_day) < 8:
return md
days = sorted(
by_day.keys(),
key=lambda x: (int(x.split("/")[0]), int(x.split("/")[1])),
)
begin = by_day[days[0]]
end = by_day[days[-1]]
has_begin = bool(re.search(r"(?:beginning|starting)\s+balance", md, flags=re.IGNORECASE))
has_end = bool(re.search(r"ending\s+balance", md, flags=re.IGNORECASE))
rows = ["<tr><th>Date</th><th>Balance</th></tr>"]
for d in days:
rows.append(f"<tr><td>{html.escape(d)}</td><td>{by_day[d]:,.2f}</td></tr>")
daily_tbl = "<table>\n" + "\n".join(rows) + "\n</table>"
extra = ["", "<div align=\"center\">Derived daily balances</div>", daily_tbl]
if not has_begin or not has_end:
extra.insert(
0,
(
f"Inferred beginning balance: {begin:,.2f}\n"
f"Inferred ending balance: {end:,.2f}"
),
)
return md.rstrip() + "\n\n" + "\n\n".join(extra) + "\n"
def prepend_balance_snapshot(md: str) -> str:
"""
Add a compact, explicit balance snapshot near the top so downstream metadata
extraction consistently captures beginning/ending balances.
"""
if not md:
return md
if "balance snapshot" in md.lower():
return md
start_bal, end_bal = _parse_statement_edge_balances(md)
if start_bal is None and end_bal is None:
# Try account-summary table fallback
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
rows = _extract_rows_plain_from_table(tm.group(0))
for r in rows:
if len(r) < 2:
continue
key = (r[0] or "").strip().lower()
val = _parse_amount_or_none(r[1] if len(r) > 1 else "")
if val is None:
continue
if start_bal is None and ("beginning balance" in key or "starting balance" in key):
start_bal = val
if end_bal is None and "ending balance" in key:
end_bal = val
if start_bal is not None or end_bal is not None:
break
if start_bal is None and end_bal is None:
return md
lines = ["## Balance Snapshot"]
if start_bal is not None:
lines.append(f"Beginning balance: {start_bal:,.2f}")
if end_bal is not None:
lines.append(f"Ending balance: {end_bal:,.2f}")
header = "\n".join(lines)
return header + "\n\n" + md
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
import pymupdf as fitz
from PIL import Image
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)
img = _enhance_raster_for_ocr(img)
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
# Deterministic guardrail: downscale oversized pages before upload.
iw, ih = img.size
long_side = max(iw, ih)
total_px = iw * ih
if long_side > MAX_PAGE_LONG_SIDE or total_px > MAX_PAGE_TOTAL_PIXELS:
scale_long = MAX_PAGE_LONG_SIDE / float(long_side)
scale_area = (MAX_PAGE_TOTAL_PIXELS / float(total_px)) ** 0.5
scale = min(scale_long, scale_area, 1.0)
nw = max(900, int(iw * scale))
nh = max(900, int(ih * scale))
img = img.resize((nw, nh), Image.Resampling.LANCZOS)
base_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}")
img_path = f"{base_path}.png"
img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
try:
size_bytes = os.path.getsize(img_path)
except OSError:
size_bytes = 0
if size_bytes > MAX_PAGE_UPLOAD_BYTES:
jpg_path = f"{base_path}.jpg"
quality = PAGE_JPEG_QUALITY
work = img
for _ in range(5):
work.save(jpg_path, "JPEG", quality=quality, optimize=True)
try:
jsize = os.path.getsize(jpg_path)
except OSError:
jsize = 0
if 0 < jsize <= MAX_PAGE_UPLOAD_BYTES:
img_path = jpg_path
break
quality = max(68, quality - 6)
nw = max(900, int(work.width * 0.93))
nh = max(900, int(work.height * 0.93))
work = work.resize((nw, nh), Image.Resampling.LANCZOS)
else:
img_path = jpg_path
page_images.append(img_path)
page_heights.append(img.height)
doc.close()
return page_images, page_heights
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
def run_ocr(uploaded_file):
if uploaded_file is None:
return "Please upload a file."
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()
page_heights: List[int] = []
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):
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(light_stabilize_markdown(fix_account_number(normalize_money_glyphs(hdr.strip()))))
if page_md and page_md.strip():
parts.append(light_stabilize_markdown(page_md.strip()))
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 merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
merged = strip_malformed_leading_table_rows(merged)
merged = strip_subtotal_rows_from_transaction_tables(merged)
merged = mask_non_monetary_long_numbers(merged)
merged = normalize_ambiguous_amount_balance_tables(merged)
merged = normalize_amount_only_transaction_tables(merged)
merged = infer_credit_debit_from_balance_deltas(merged)
merged = sanitize_transaction_tables(merged)
merged = stabilize_table_markup(merged, rounds=4)
merged = enrich_transaction_tables_with_daily_balances(merged)
# Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
merged = prepend_balance_snapshot(merged)
merged = stabilize_table_markup(merged, rounds=2)
merged = repair_thead_cell_semantics(merged)
merged = strip_degenerate_html_tables(merged)
return merged
except Exception as e:
import traceback
log.exception("run_ocr failed: %s", e)
return f"Error: {e}\n\n{traceback.format_exc()}"
finally:
for p in page_images:
try:
if (
isinstance(p, str)
and "glmocr_page_" in os.path.basename(p)
and (p.endswith(".png") or p.endswith(".jpg") or p.endswith(".jpeg"))
):
os.unlink(p)
except Exception:
pass
def _create_gradio_demo():
import gradio as gr
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."
)
file_in = gr.File(
label="Upload PDF or image",
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
)
run_btn = gr.Button("Run OCR", variant="primary")
out = gr.Textbox(lines=40, label="Output (markdown / light HTML)")
run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
return demo
if __name__ == "__main__":
_create_gradio_demo().launch()