headings are picked up correctly.
"""
window = text[max(0, table_start - _HEADING_SCAN_WINDOW): table_start]
clean = re.sub(r"<[^>]+>", " ", window)
lines = [ln.strip() for ln in clean.splitlines() if ln.strip()]
for line in reversed(lines):
if len(line) > 3 and "---" not in line:
return line
return ""
def _strip_credits_prefix_from_debit_da3_table(text: str) -> str:
"""
Fix 28: When OCR prepends the rows of a preceding credits/deposits DA3 table
onto the top of a debits/withdrawals DA3 table, strip that prefix.
Safety guards (ALL must pass):
1. Both tables must be valid DA3 (Date|Description|Amount header).
2. Target (later) table must follow a debit/withdrawal heading.
3. Source (earlier) table must follow a credit/deposit heading.
4. Only the contiguous leading prefix of matching rows is removed.
5. At least 1 data row must remain in the target after stripping.
6. Strip count must be >= 1.
7. Entire function wrapped in try/except — returns input unchanged on error.
"""
if not text or "
", re.DOTALL | re.IGNORECASE)
matches = list(pattern.finditer(text))
if len(matches) < 2:
return text
# Collect all DA3 tables with their heading context.
da3_tables = []
for m in matches:
g = _parse_da3_grid_from_table_html(m.group(0))
if not g or not _is_da3_header(g):
continue
heading = _get_heading_before_table(text, m.start())
da3_tables.append({"match": m, "grid": g, "heading": heading})
if len(da3_tables) < 2:
return text
def _row_keys(grid):
keys = set()
for row in grid[1:]:
cells = [str(c or "").strip() for c in row]
if any(cells):
keys.add(_transaction_row_dedupe_key(cells))
return keys
# For each DA3 table preceded by a debit heading, look backward for the
# nearest DA3 table preceded by a credit heading, and measure how many
# of the debit table's leading rows appear in that credit table.
drop_map = {} # table match start -> number of rows to strip
for i in range(1, len(da3_tables)):
ti = da3_tables[i]
hi = ti["heading"].lower()
if not _DEBIT_HEADING_KEYWORDS.search(hi):
continue
gi = ti["grid"]
data_i = gi[1:]
if len(data_i) < 2:
continue
for j in range(i - 1, -1, -1):
tj = da3_tables[j]
hj = tj["heading"].lower()
if not _CREDIT_HEADING_KEYWORDS.search(hj):
continue
gj = tj["grid"]
keys_j = _row_keys(gj)
if not keys_j:
continue
# Count the contiguous leading rows of data_i that are in keys_j.
strip_count = 0
for row in data_i:
cells = [str(c or "").strip() for c in row]
k = _transaction_row_dedupe_key(cells)
if k in keys_j:
strip_count += 1
else:
break
if strip_count < 1:
continue
# Guard: at least 1 data row must survive.
if len(data_i) - strip_count < 1:
continue
drop_map[ti["match"].start()] = strip_count
log.info(
"Fix 28: stripped %d leading credit row(s) from debit table at offset %d",
strip_count, ti["match"].start(),
)
break # found nearest credit table — stop looking further back
if not drop_map:
return text
# Apply the strip.
out_chunks = []
last = 0
for m in matches:
out_chunks.append(text[last: m.start()])
if m.start() in drop_map:
strip_n = drop_map[m.start()]
g = _parse_da3_grid_from_table_html(m.group(0))
if g and _is_da3_header(g) and len(g) - 1 - strip_n >= 1:
new_grid = [g[0]] + g[1 + strip_n:]
out_chunks.append(_grid_to_html(new_grid))
else:
out_chunks.append(m.group(0))
else:
out_chunks.append(m.group(0))
last = m.end()
out_chunks.append(text[last:])
return "".join(out_chunks)
except Exception as e:
log.warning("_strip_credits_prefix_from_debit_da3_table failed: %s", e)
return text
# --------------------------
# Fix 29: Move misplaced debit heading to before its orphan DA3 table
# --------------------------
# Centered div heading pattern (BoA / generic style)
_CENTER_DIV_HDR_RE = re.compile(
r'
\s*(.*?)\s*
',
re.IGNORECASE | re.DOTALL,
)
_HEADING_SCAN_FORWARD = 800 # chars after table end to search for misplaced heading
def _fix29_move_debit_heading_before_orphan_table(text: str) -> str:
"""
Fix 29: When OCR places a debit/withdrawal section heading AFTER the withdrawals table
instead of before it, move the heading to its correct position (just before the table).
Pattern detected:
[credit/deposit DA3 table] ...no debit heading... [orphan DA3 table] [debit heading]
where no other DA3 table separates the orphan table from the debit heading.
Action: move the debit heading to just before the orphan DA3 table.
Safety guards (ALL must pass):
1. Orphan table must be a valid DA3 (Date|Description|Amount).
2. A debit heading must appear AFTER the orphan table within _HEADING_SCAN_FORWARD chars.
3. No debit heading appears immediately before the orphan table (within _HEADING_SCAN_WINDOW).
4. A credit/deposit heading must exist before the orphan table (within 1200 chars).
5. No other table appears between the orphan table end and the debit heading.
6. Entire function wrapped in try/except -- returns input unchanged on any error.
"""
if not text or "
", re.DOTALL | re.IGNORECASE)
matches = list(tbl_pat.finditer(text))
if len(matches) < 2:
return text
da3_matches = [m for m in matches if _parse_da3_grid_from_table_html(m.group(0))]
if len(da3_matches) < 2:
return text
moves = []
for i in range(1, len(da3_matches)):
orphan_m = da3_matches[i]
orphan_start = orphan_m.start()
orphan_end = orphan_m.end()
# Guard 3: no debit heading already before the orphan table
before_window = text[max(0, orphan_start - _HEADING_SCAN_WINDOW): orphan_start]
before_clean = re.sub(r"<[^>]+>", " ", before_window)
lines_before = [l.strip() for l in before_clean.splitlines() if l.strip()]
last_meaningful = ""
for l in reversed(lines_before):
if len(l) > 3 and "---" not in l:
last_meaningful = l
break
if _DEBIT_HEADING_KEYWORDS.search(last_meaningful):
continue # already has debit heading before it
# Guard 4: credit context must precede the orphan table
big_before = text[max(0, orphan_start - 1200): orphan_start]
if not _CREDIT_HEADING_KEYWORDS.search(re.sub(r"<[^>]+>", " ", big_before)):
continue
# Guard 5: no other table between orphan end and the potential debit heading
next_tbl_m = tbl_pat.search(text, orphan_end)
# Guard 2: find debit heading after the orphan table (within forward window)
dh_start = dh_end = None
for div_m in _CENTER_DIV_HDR_RE.finditer(text, orphan_end, orphan_end + _HEADING_SCAN_FORWARD):
div_clean = re.sub(r"<[^>]+>", "", div_m.group(1)).strip()
if _DEBIT_HEADING_KEYWORDS.search(div_clean) and not _CREDIT_HEADING_KEYWORDS.search(div_clean):
# Guard 5: no table should be between orphan_end and this heading
if next_tbl_m and next_tbl_m.start() < div_m.start():
break # another table intervenes
dh_start = div_m.start()
dh_end = div_m.end()
break
if dh_start is None:
continue
moves.append((orphan_start, dh_start, dh_end))
if not moves:
return text
# Apply moves in reverse order to preserve offsets
for orphan_start, dh_start, dh_end in reversed(moves):
orphan_m2 = next((m for m in tbl_pat.finditer(text) if m.start() == orphan_start), None)
if not orphan_m2:
continue
orphan_end = orphan_m2.end()
heading_html = text[dh_start: dh_end]
gap = text[orphan_end: dh_start]
orphan_html = text[orphan_start: orphan_end]
part_before = text[:orphan_start]
part_after = text[dh_end:]
text = part_before + heading_html + "\n\n" + orphan_html + gap.rstrip() + part_after
log.info(
"Fix 29: moved debit heading (was at offset %d) to before orphan DA3 table (offset %d)",
dh_start, orphan_start,
)
return text
except Exception as e:
log.warning("_fix29_move_debit_heading_before_orphan_table failed: %s", e)
return text
def stabilize_tables_and_text(page_md: str) -> str:
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(b)
stabilized = "\n\n".join(out_blocks)
stabilized = convert_plaintext_bank_sections(stabilized)
stabilized = _strip_orphan_continued_da3_flatline(stabilized)
stabilized = _strip_standalone_continued_banner_line(stabilized)
stabilized = normalize_html_tables(stabilized)
stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
stabilized = _strip_orphan_continued_da3_flatline(stabilized)
stabilized = _strip_standalone_continued_banner_line(stabilized)
return close_unclosed_html(stabilized)
# --------------------------
# PDF rendering with padding
# --------------------------
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
import pymupdf as fitz
from PIL import Image, ImageEnhance
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)
if ENABLE_CONTRAST:
img = ImageEnhance.Contrast(img).enhance(1.12)
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
img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png")
img.save(img_path, "PNG", compress_level=6)
page_images.append(img_path)
page_heights.append(img.height)
doc.close()
return page_images, page_heights
# --------------------------
# GLM-OCR result extraction
# --------------------------
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
# --------------------------
# Main entry
# --------------------------
def run_ocr(uploaded_file):
if uploaded_file is None:
return "Please upload a file."
page_images = []
try:
path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
is_pdf = path.lower().endswith(".pdf")
is_nfcu_doc = _is_nfcu_document(path) if is_pdf else False
parser = get_parser()
page_heights = []
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(normalize_html_tables(fix_account_number(normalize_money_glyphs(hdr.strip()))))
if page_md and page_md.strip():
stabilized = stabilize_tables_and_text(page_md.strip())
if is_pdf and not is_nfcu_doc:
stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
stabilized = _strip_orphan_continued_da3_flatline(stabilized)
stabilized = _strip_standalone_continued_banner_line(stabilized)
stabilized = normalize_html_tables(stabilized)
stabilized = _trim_adjacent_da3_suffix_duplicating_next_table_prefix(stabilized)
stabilized = _split_ucb_deposits_electronic_sections_html(stabilized)
stabilized = _patch_da3_msft_g_ref_amounts_across_tables(stabilized)
stabilized = _dedupe_subset_transaction_tables_html(stabilized)
stabilized = _drop_da3_misplaced_debit_after_deposit_heading_tables(stabilized)
stabilized = _strip_credits_prefix_from_debit_da3_table(stabilized) # Fix 28
stabilized = _fix29_move_debit_heading_before_orphan_table(stabilized) # Fix 29
stabilized = _strip_standalone_continued_banner_line(stabilized)
elif is_pdf and is_nfcu_doc:
nfcu_summary = _extract_nfcu_summary_table(path, page_num)
if nfcu_summary:
stabilized = _replace_nfcu_summary_table(stabilized, nfcu_summary)
if is_pdf:
try:
needs_checks = 'class="jb-summary-checks"' not in stabilized
needs_dab = "daily account balance" not in stabilized.lower()
if needs_checks or needs_dab:
_txt4d = ""
try:
from pdfminer.high_level import extract_text as _pm_extract
_txt4d = _pm_extract(path, page_numbers=[page_num])
except Exception:
pass
if not _txt4d:
try:
import pymupdf as _fitz4d
_d4 = _fitz4d.open(path)
_txt4d = _d4[page_num].get_text()
_d4.close()
except Exception:
pass
if _txt4d:
jb_extra = _extract_jb_summary_sections(_txt4d, needs_checks, needs_dab)
if jb_extra:
stabilized = stabilized.rstrip() + "\n\n" + jb_extra
except Exception:
pass
parts.append(stabilized)
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 all_pages and merged and not merged.startswith("Error") and "