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
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 = _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 = _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 "