Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -26,8 +26,6 @@ Included (data-driven, no institution names):
|
|
| 26 |
but do not disable an entire table. Stabilization runs in multiple passes.
|
| 27 |
- Split single cells that clearly contain transaction amount + trailing balance
|
| 28 |
(two money tokens, tight gap) into two cells so classifiers can see a balance column.
|
| 29 |
-
- Long runs of plain-text ledger lines (date + description + amount, optional balance)
|
| 30 |
-
are coerced into one HTML table when 5+ consecutive lines match generic patterns.
|
| 31 |
- thead uses th only; degenerate empty/sparse non-financial tables are dropped.
|
| 32 |
|
| 33 |
Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
|
|
@@ -55,6 +53,7 @@ import logging
|
|
| 55 |
import os
|
| 56 |
import re
|
| 57 |
import tempfile
|
|
|
|
| 58 |
from collections import Counter
|
| 59 |
from typing import List, Optional, Tuple
|
| 60 |
|
|
@@ -86,12 +85,12 @@ if not GLMOCR_API_KEY:
|
|
| 86 |
|
| 87 |
# Rasterization: higher scale = more pixels per PDF point (helps small type,
|
| 88 |
# boxed headers, and narrow columns). Same constant for all uploads.
|
| 89 |
-
RENDER_SCALE = 3.
|
| 90 |
|
| 91 |
# White margin as a fraction of page width/height after render. Extra right
|
| 92 |
# margin helps right-aligned currency columns that hug the page edge.
|
| 93 |
PAD_LEFT_FRAC = 0.035
|
| 94 |
-
PAD_RIGHT_FRAC = 0.
|
| 95 |
PAD_TOP_FRAC = 0.018
|
| 96 |
PAD_BOTTOM_FRAC = 0.018
|
| 97 |
|
|
@@ -118,11 +117,6 @@ MIN_CROP_PIXELS = 112 * 112
|
|
| 118 |
PAGE_PNG_COMPRESS_LEVEL = 3
|
| 119 |
# JPEG quality for small header/footer crops sent to the API.
|
| 120 |
ZONE_JPEG_QUALITY = 95
|
| 121 |
-
PAGE_JPEG_QUALITY = 88
|
| 122 |
-
MAX_PAGE_UPLOAD_BYTES = 9_200_000
|
| 123 |
-
# Keep page PNGs under MaaS input constraints to avoid 400 errors.
|
| 124 |
-
MAX_PAGE_LONG_SIDE = 3600
|
| 125 |
-
MAX_PAGE_TOTAL_PIXELS = 7_800_000
|
| 126 |
|
| 127 |
MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
|
| 128 |
|
|
@@ -169,7 +163,7 @@ if CONFIG_PATH:
|
|
| 169 |
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 170 |
config = yaml.safe_load(f)
|
| 171 |
config.setdefault("pipeline", {}).setdefault("maas", {})
|
| 172 |
-
config["pipeline"]["maas"]["enabled"] =
|
| 173 |
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
|
| 174 |
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
| 175 |
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
@@ -743,323 +737,6 @@ def strip_degenerate_html_tables(md: str) -> str:
|
|
| 743 |
_GLUED_HEADER_ROW_RE = re.compile(r"date\d{1,2}/\d{1,2}|typedeposit|amount\d", re.IGNORECASE)
|
| 744 |
|
| 745 |
|
| 746 |
-
def strip_malformed_leading_table_rows(md: str) -> str:
|
| 747 |
-
"""
|
| 748 |
-
Remove malformed leading rows in HTML tables when they are clearly bogus
|
| 749 |
-
header artifacts and the table already contains a proper header row below.
|
| 750 |
-
"""
|
| 751 |
-
if not md or "<table" not in md.lower():
|
| 752 |
-
return md
|
| 753 |
-
|
| 754 |
-
def repl_table(m: re.Match) -> str:
|
| 755 |
-
full = m.group(0)
|
| 756 |
-
trs = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
|
| 757 |
-
if len(trs) < 2:
|
| 758 |
-
return full
|
| 759 |
-
|
| 760 |
-
first = trs[0].group(0)
|
| 761 |
-
second = trs[1].group(0)
|
| 762 |
-
first_plain = _cell_plain_text(first).lower()
|
| 763 |
-
second_plain = _cell_plain_text(second).lower()
|
| 764 |
-
|
| 765 |
-
first_is_malformed = bool(_GLUED_HEADER_ROW_RE.search(first_plain))
|
| 766 |
-
second_looks_header = (
|
| 767 |
-
("date" in second_plain and "description" in second_plain)
|
| 768 |
-
or ("posting date" in second_plain and "description" in second_plain)
|
| 769 |
-
or ("date" in second_plain and "balance" in second_plain)
|
| 770 |
-
)
|
| 771 |
-
if not (first_is_malformed and second_looks_header):
|
| 772 |
-
return full
|
| 773 |
-
|
| 774 |
-
return full.replace(first, "", 1)
|
| 775 |
-
|
| 776 |
-
return re.sub(
|
| 777 |
-
r"<table\b[^>]*>.*?</table>",
|
| 778 |
-
repl_table,
|
| 779 |
-
md,
|
| 780 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 781 |
-
)
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
def strip_subtotal_rows_from_transaction_tables(md: str) -> str:
|
| 785 |
-
"""
|
| 786 |
-
Remove subtotal/total line-item rows from transaction tables so aggregate
|
| 787 |
-
amounts are not mistaken as individual transactions.
|
| 788 |
-
"""
|
| 789 |
-
if not md or "<table" not in md.lower():
|
| 790 |
-
return md
|
| 791 |
-
|
| 792 |
-
def repl_table(m: re.Match) -> str:
|
| 793 |
-
full = m.group(0)
|
| 794 |
-
rows = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", full, flags=re.IGNORECASE | re.DOTALL))
|
| 795 |
-
if len(rows) < 2:
|
| 796 |
-
return full
|
| 797 |
-
# Some tables start with a title row, then the actual header appears in row 2/3.
|
| 798 |
-
sample_headers = [_cell_plain_text(r.group(0)).lower() for r in rows[:4]]
|
| 799 |
-
is_txn_like = any(
|
| 800 |
-
("date" in hp or "posting date" in hp)
|
| 801 |
-
and ("description" in hp)
|
| 802 |
-
and ("amount" in hp or "debit" in hp or "credit" in hp)
|
| 803 |
-
for hp in sample_headers
|
| 804 |
-
)
|
| 805 |
-
if not is_txn_like:
|
| 806 |
-
return full
|
| 807 |
-
|
| 808 |
-
out = full
|
| 809 |
-
for r in rows[1:]:
|
| 810 |
-
rp = _cell_plain_text(r.group(0)).lower()
|
| 811 |
-
if "subtotal" in rp or rp.startswith("total "):
|
| 812 |
-
out = out.replace(r.group(0), "", 1)
|
| 813 |
-
return out
|
| 814 |
-
|
| 815 |
-
return re.sub(
|
| 816 |
-
r"<table\b[^>]*>.*?</table>",
|
| 817 |
-
repl_table,
|
| 818 |
-
md,
|
| 819 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 820 |
-
)
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
# Match long digit runs even when OCR glues trailing letters (e.g. 9257403599CCD).
|
| 824 |
-
_LONG_NUM_TOKEN = re.compile(r"(?<!\d)\d{7,}(?!\d)")
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
def mask_non_monetary_long_numbers(md: str) -> str:
|
| 828 |
-
"""
|
| 829 |
-
Prevent long reference/trace/account IDs from being interpreted as monetary
|
| 830 |
-
values by downstream transaction extraction.
|
| 831 |
-
"""
|
| 832 |
-
if not md:
|
| 833 |
-
return md
|
| 834 |
-
|
| 835 |
-
def repl(m: re.Match) -> str:
|
| 836 |
-
tok = m.group(0)
|
| 837 |
-
# Keep common money-like tokens (handled elsewhere with decimal cents).
|
| 838 |
-
if re.match(r"^\d+\.\d{2}$", tok):
|
| 839 |
-
return tok
|
| 840 |
-
# Keep YYYYMMDD-like date compact tokens.
|
| 841 |
-
if len(tok) == 8 and tok.startswith(("19", "20")):
|
| 842 |
-
return tok
|
| 843 |
-
# Replace digits entirely so downstream cannot reinterpret ID tokens as money.
|
| 844 |
-
return "IDNUM"
|
| 845 |
-
|
| 846 |
-
return _LONG_NUM_TOKEN.sub(repl, md)
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
def directionalize_amount_headers(md: str) -> str:
|
| 850 |
-
"""
|
| 851 |
-
Convert ambiguous 'Amount' headers into 'Credit' or 'Debit' when table
|
| 852 |
-
context clearly indicates transaction direction (e.g. Deposits, Checks).
|
| 853 |
-
"""
|
| 854 |
-
if not md or "<table" not in md.lower():
|
| 855 |
-
return md
|
| 856 |
-
|
| 857 |
-
def map_header_token(label: str, ctx: str) -> str:
|
| 858 |
-
low = label.strip().lower()
|
| 859 |
-
if "amount" not in low:
|
| 860 |
-
return label
|
| 861 |
-
if re.search(r"\bcredit|deposit|deposits|incoming|received|interest earned|payment received\b", ctx):
|
| 862 |
-
return re.sub(r"amount", "Credit", label, flags=re.IGNORECASE)
|
| 863 |
-
if re.search(r"\bdebit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|payments\b", ctx):
|
| 864 |
-
return re.sub(r"amount", "Debit", label, flags=re.IGNORECASE)
|
| 865 |
-
return label
|
| 866 |
-
|
| 867 |
-
def repl_table(m: re.Match) -> str:
|
| 868 |
-
full = m.group(0)
|
| 869 |
-
# Table-local context from headers and first rows.
|
| 870 |
-
ctx = _cell_plain_text(full).lower()
|
| 871 |
-
changed = False
|
| 872 |
-
|
| 873 |
-
def repl_th(thm: re.Match) -> str:
|
| 874 |
-
nonlocal changed
|
| 875 |
-
tag = thm.group(0)
|
| 876 |
-
inner_m = re.search(r"<th\b[^>]*>(.*?)</th>", tag, flags=re.IGNORECASE | re.DOTALL)
|
| 877 |
-
if not inner_m:
|
| 878 |
-
return tag
|
| 879 |
-
inner = inner_m.group(1)
|
| 880 |
-
plain = _cell_plain_text(inner)
|
| 881 |
-
mapped = map_header_token(plain, ctx)
|
| 882 |
-
if mapped == plain:
|
| 883 |
-
return tag
|
| 884 |
-
changed = True
|
| 885 |
-
return re.sub(
|
| 886 |
-
r"(<th\b[^>]*>)(.*?)(</th>)",
|
| 887 |
-
rf"\1{html.escape(mapped)}\3",
|
| 888 |
-
tag,
|
| 889 |
-
count=1,
|
| 890 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 891 |
-
)
|
| 892 |
-
|
| 893 |
-
out = re.sub(r"<th\b[^>]*>.*?</th>", repl_th, full, flags=re.IGNORECASE | re.DOTALL)
|
| 894 |
-
if changed:
|
| 895 |
-
return out
|
| 896 |
-
# Fallback for OCR tables that put header row in <td>.
|
| 897 |
-
if "amount" in ctx and ("deposit" in ctx or "debit" in ctx or "withdraw" in ctx or "check" in ctx or "fee" in ctx):
|
| 898 |
-
out2 = re.sub(
|
| 899 |
-
r"(<td\b[^>]*>\s*)amount(\s*</td>)",
|
| 900 |
-
lambda mm: mm.group(1)
|
| 901 |
-
+ (
|
| 902 |
-
"Credit"
|
| 903 |
-
if ("deposit" in ctx or "credit" in ctx or "incoming" in ctx or "received" in ctx)
|
| 904 |
-
else "Debit"
|
| 905 |
-
)
|
| 906 |
-
+ mm.group(2),
|
| 907 |
-
full,
|
| 908 |
-
flags=re.IGNORECASE,
|
| 909 |
-
)
|
| 910 |
-
return out2
|
| 911 |
-
return full
|
| 912 |
-
|
| 913 |
-
return re.sub(
|
| 914 |
-
r"<table\b[^>]*>.*?</table>",
|
| 915 |
-
repl_table,
|
| 916 |
-
md,
|
| 917 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 918 |
-
)
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
def directionalize_amount_headers_by_nearby_context(md: str) -> str:
|
| 922 |
-
"""
|
| 923 |
-
Use nearby narrative labels around each table (outside HTML cells) to set
|
| 924 |
-
Amount->Credit/Debit when the table itself is ambiguous.
|
| 925 |
-
"""
|
| 926 |
-
if not md or "<table" not in md.lower():
|
| 927 |
-
return md
|
| 928 |
-
|
| 929 |
-
credit_hint_re = re.compile(
|
| 930 |
-
r"\b(all\s+credit\s+activity|credit\s+activity|deposits?|credits?|money\s+in)\b",
|
| 931 |
-
re.IGNORECASE,
|
| 932 |
-
)
|
| 933 |
-
debit_hint_re = re.compile(
|
| 934 |
-
r"\b(all\s+debit\s+activity|debit\s+activity|withdrawals?|debits?|checks?|money\s+out)\b",
|
| 935 |
-
re.IGNORECASE,
|
| 936 |
-
)
|
| 937 |
-
|
| 938 |
-
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
|
| 939 |
-
out_parts: List[str] = []
|
| 940 |
-
last_end = 0
|
| 941 |
-
for tm in table_re.finditer(md):
|
| 942 |
-
out_parts.append(md[last_end:tm.start()])
|
| 943 |
-
tbl = tm.group(0)
|
| 944 |
-
|
| 945 |
-
left = re.sub(r"<[^>]+>", " ", md[max(0, tm.start() - 700): tm.start()])
|
| 946 |
-
right = re.sub(r"<[^>]+>", " ", md[tm.end(): min(len(md), tm.end() + 250)])
|
| 947 |
-
ctx = f"{left} {right}"
|
| 948 |
-
is_credit = bool(credit_hint_re.search(ctx))
|
| 949 |
-
is_debit = bool(debit_hint_re.search(ctx))
|
| 950 |
-
if is_credit ^ is_debit:
|
| 951 |
-
w = "Credit" if is_credit else "Debit"
|
| 952 |
-
tbl = re.sub(r"(<th\b[^>]*>\s*)amount(\s*</th>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
|
| 953 |
-
tbl = re.sub(r"(<td\b[^>]*>\s*)amount(\s*</td>)", rf"\1{w}\2", tbl, flags=re.IGNORECASE)
|
| 954 |
-
|
| 955 |
-
out_parts.append(tbl)
|
| 956 |
-
last_end = tm.end()
|
| 957 |
-
out_parts.append(md[last_end:])
|
| 958 |
-
return "".join(out_parts)
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
def _strip_trailing_money_tokens(rest: str, max_take: int = 2) -> Tuple[str, List[str]]:
|
| 962 |
-
"""Strip 1–2 currency tokens from the right of *rest*; return (description, tokens oldest-first)."""
|
| 963 |
-
cur = rest.rstrip()
|
| 964 |
-
toks: List[str] = []
|
| 965 |
-
for _ in range(max_take):
|
| 966 |
-
cur = cur.rstrip()
|
| 967 |
-
m = _EOL_MONEY.search(cur)
|
| 968 |
-
if not m or m.end() != len(cur):
|
| 969 |
-
break
|
| 970 |
-
toks.append(m.group().strip())
|
| 971 |
-
cur = cur[: m.start()].rstrip()
|
| 972 |
-
toks.reverse()
|
| 973 |
-
return cur, toks
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
def _parse_ledger_line(line: str) -> Optional[Tuple[str, str, str, str]]:
|
| 977 |
-
"""
|
| 978 |
-
If *line* looks like a bank-style ledger row (date, description, amount, optional balance),
|
| 979 |
-
return (date, description, amount, balance_or_empty); else None.
|
| 980 |
-
"""
|
| 981 |
-
ln = line.strip()
|
| 982 |
-
if not ln or ln[0] in "#|!<":
|
| 983 |
-
return None
|
| 984 |
-
mm = re.match(
|
| 985 |
-
r"^(?P<d>(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|"
|
| 986 |
-
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}))"
|
| 987 |
-
r"\s+",
|
| 988 |
-
ln,
|
| 989 |
-
re.I,
|
| 990 |
-
)
|
| 991 |
-
if not mm:
|
| 992 |
-
return None
|
| 993 |
-
d = mm.group("d")
|
| 994 |
-
tail = ln[mm.end() :]
|
| 995 |
-
desc, toks = _strip_trailing_money_tokens(tail, 2)
|
| 996 |
-
desc = desc.strip()
|
| 997 |
-
if not toks or len(desc) < 2 or len(desc) > 420:
|
| 998 |
-
return None
|
| 999 |
-
amt = toks[0]
|
| 1000 |
-
bal = toks[1] if len(toks) > 1 else ""
|
| 1001 |
-
return (d, desc, amt, bal)
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
def _ledger_rows_to_html(rows: List[Tuple[str, str, str, str]]) -> str:
|
| 1005 |
-
has_bal = any(r[3] for r in rows)
|
| 1006 |
-
head = (
|
| 1007 |
-
"<tr><th>Date</th><th>Description</th><th>Amount</th>"
|
| 1008 |
-
+ ("<th>Balance</th>" if has_bal else "")
|
| 1009 |
-
+ "</tr>"
|
| 1010 |
-
)
|
| 1011 |
-
body_lines = []
|
| 1012 |
-
for d, desc, amt, bal in rows:
|
| 1013 |
-
cells = [
|
| 1014 |
-
f"<td>{html.escape(d)}</td>",
|
| 1015 |
-
f"<td>{html.escape(desc)}</td>",
|
| 1016 |
-
f"<td>{html.escape(amt)}</td>",
|
| 1017 |
-
]
|
| 1018 |
-
if has_bal:
|
| 1019 |
-
cells.append(f"<td>{html.escape(bal)}</td>" if bal else "<td></td>")
|
| 1020 |
-
body_lines.append("<tr>" + "".join(cells) + "</tr>")
|
| 1021 |
-
return "<table>\n" + head + "\n" + "\n".join(body_lines) + "\n</table>"
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
def coalesce_loose_ledger_lines(text: str) -> str:
|
| 1025 |
-
"""
|
| 1026 |
-
When the model emits many consecutive plain-text ledger lines (no HTML table),
|
| 1027 |
-
downstream classification sees no tables and skips. Convert long runs of
|
| 1028 |
-
generic date + amount lines into a single HTML table. Requires 5+ matching
|
| 1029 |
-
consecutive non-empty lines; does not match institution names.
|
| 1030 |
-
"""
|
| 1031 |
-
if not text or "<table" in text.lower():
|
| 1032 |
-
return text
|
| 1033 |
-
lines = text.split("\n")
|
| 1034 |
-
out: List[str] = []
|
| 1035 |
-
i = 0
|
| 1036 |
-
while i < len(lines):
|
| 1037 |
-
if not lines[i].strip():
|
| 1038 |
-
out.append(lines[i])
|
| 1039 |
-
i += 1
|
| 1040 |
-
continue
|
| 1041 |
-
run_rows: List[Tuple[str, str, str, str]] = []
|
| 1042 |
-
j = i
|
| 1043 |
-
while j < len(lines):
|
| 1044 |
-
raw = lines[j]
|
| 1045 |
-
if not raw.strip():
|
| 1046 |
-
break
|
| 1047 |
-
parsed = _parse_ledger_line(raw)
|
| 1048 |
-
if parsed is None:
|
| 1049 |
-
break
|
| 1050 |
-
run_rows.append(parsed)
|
| 1051 |
-
j += 1
|
| 1052 |
-
if len(run_rows) >= 5:
|
| 1053 |
-
if i > 0 and out and out[-1].strip():
|
| 1054 |
-
out.append("")
|
| 1055 |
-
out.append(_ledger_rows_to_html(run_rows))
|
| 1056 |
-
i = j
|
| 1057 |
-
continue
|
| 1058 |
-
out.append(lines[i])
|
| 1059 |
-
i += 1
|
| 1060 |
-
return "\n".join(out)
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
def looks_like_markdown_table(block: str) -> bool:
|
| 1064 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 1065 |
if len(lines) < 2:
|
|
@@ -1117,7 +794,7 @@ def normalize_money_glyphs(text: str) -> str:
|
|
| 1117 |
|
| 1118 |
|
| 1119 |
def light_stabilize_markdown(page_md: str) -> str:
|
| 1120 |
-
"""Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs;
|
| 1121 |
if not page_md:
|
| 1122 |
return page_md
|
| 1123 |
page_md = normalize_money_glyphs(page_md)
|
|
@@ -1127,27 +804,34 @@ def light_stabilize_markdown(page_md: str) -> str:
|
|
| 1127 |
if looks_like_markdown_table(b):
|
| 1128 |
out_blocks.append(md_table_to_html(b))
|
| 1129 |
else:
|
| 1130 |
-
out_blocks.append(
|
| 1131 |
-
merged = close_unclosed_html(
|
| 1132 |
-
merged = strip_malformed_leading_table_rows(merged)
|
| 1133 |
-
merged = strip_subtotal_rows_from_transaction_tables(merged)
|
| 1134 |
-
merged = mask_non_monetary_long_numbers(merged)
|
| 1135 |
merged = repair_thead_cell_semantics(merged)
|
| 1136 |
-
merged = stabilize_table_markup(merged, rounds=
|
| 1137 |
merged = strip_degenerate_html_tables(merged)
|
| 1138 |
return merged
|
| 1139 |
|
| 1140 |
|
| 1141 |
def _parse_amount_or_none(s: str):
|
| 1142 |
-
|
| 1143 |
-
if not
|
|
|
|
|
|
|
| 1144 |
return None
|
|
|
|
| 1145 |
t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
|
| 1146 |
t = t.replace("−", "-").replace("–", "-").replace("—", "-")
|
| 1147 |
if not re.search(r"\d", t):
|
| 1148 |
return None
|
|
|
|
|
|
|
| 1149 |
try:
|
| 1150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1151 |
except Exception:
|
| 1152 |
return None
|
| 1153 |
|
|
@@ -1171,493 +855,279 @@ def _fmt_money(v: float) -> str:
|
|
| 1171 |
|
| 1172 |
def _is_date_like(s: str) -> bool:
|
| 1173 |
t = (s or "").strip()
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
""
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
credit_hint_re = re.compile(
|
| 1187 |
-
r"\b(deposit|deposits|credit|credits|incoming|received|interest earned|refund|return)\b",
|
| 1188 |
-
re.IGNORECASE,
|
| 1189 |
-
)
|
| 1190 |
-
debit_hint_re = re.compile(
|
| 1191 |
-
r"\b(debit|debits|withdrawal|withdrawals|check|checks|fee|fees|charge|charges|payment|transfer out|purchase)\b",
|
| 1192 |
-
re.IGNORECASE,
|
| 1193 |
)
|
| 1194 |
|
| 1195 |
-
def repl_table(m: re.Match) -> str:
|
| 1196 |
-
full = m.group(0)
|
| 1197 |
-
rows = _extract_rows_plain_from_table(full)
|
| 1198 |
-
if len(rows) < 3:
|
| 1199 |
-
return full
|
| 1200 |
-
|
| 1201 |
-
hdr = [c.strip().lower() for c in rows[0]]
|
| 1202 |
-
amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
|
| 1203 |
-
balance_idxs = [i for i, c in enumerate(hdr) if "balance" in c]
|
| 1204 |
-
has_credit = any("credit" in c for c in hdr)
|
| 1205 |
-
has_debit = any("debit" in c for c in hdr)
|
| 1206 |
-
date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
|
| 1207 |
-
desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
|
| 1208 |
-
|
| 1209 |
-
if has_credit or has_debit or not amount_idxs or not balance_idxs:
|
| 1210 |
-
return full
|
| 1211 |
-
|
| 1212 |
-
amount_idx = amount_idxs[0]
|
| 1213 |
-
balance_idx = balance_idxs[0]
|
| 1214 |
-
date_idx = date_idxs[0] if date_idxs else 0
|
| 1215 |
-
desc_idx = desc_idxs[0] if desc_idxs else min(1, max(0, len(rows[0]) - 1))
|
| 1216 |
-
|
| 1217 |
-
new_rows: List[List[str]] = [["Date", "Description", "Credit", "Debit", "Balance"]]
|
| 1218 |
-
prev_bal = None
|
| 1219 |
-
section_hint = ""
|
| 1220 |
-
converted = 0
|
| 1221 |
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
padded = r + [""] * (max(amount_idx, balance_idx, date_idx, desc_idx) + 1 - len(r))
|
| 1226 |
-
date_txt = (padded[date_idx] or "").strip()
|
| 1227 |
-
desc_txt = (padded[desc_idx] or "").strip()
|
| 1228 |
-
amt_txt = (padded[amount_idx] or "").strip()
|
| 1229 |
-
bal_txt = (padded[balance_idx] or "").strip()
|
| 1230 |
-
amt = _parse_amount_or_none(amt_txt)
|
| 1231 |
-
bal = _parse_amount_or_none(bal_txt)
|
| 1232 |
-
|
| 1233 |
-
row_blob = " ".join((c or "").strip() for c in padded).strip()
|
| 1234 |
-
if row_blob and not _is_date_like(date_txt) and amt is None:
|
| 1235 |
-
section_hint = row_blob
|
| 1236 |
-
continue
|
| 1237 |
-
if amt is None and bal is None:
|
| 1238 |
-
continue
|
| 1239 |
-
|
| 1240 |
-
direction = ""
|
| 1241 |
-
# Primary: infer from balance delta between consecutive rows.
|
| 1242 |
-
if amt is not None and bal is not None and prev_bal is not None:
|
| 1243 |
-
delta = bal - prev_bal
|
| 1244 |
-
tol = max(0.55, 0.025 * abs(amt))
|
| 1245 |
-
if abs(abs(delta) - abs(amt)) <= tol:
|
| 1246 |
-
direction = "credit" if delta > 0 else "debit"
|
| 1247 |
-
|
| 1248 |
-
# Secondary: section + description lexical hints.
|
| 1249 |
-
ctx = f"{section_hint} {desc_txt}"
|
| 1250 |
-
if not direction and credit_hint_re.search(ctx):
|
| 1251 |
-
direction = "credit"
|
| 1252 |
-
if not direction and debit_hint_re.search(ctx):
|
| 1253 |
-
direction = "debit"
|
| 1254 |
-
|
| 1255 |
-
credit = ""
|
| 1256 |
-
debit = ""
|
| 1257 |
-
if amt is not None:
|
| 1258 |
-
if direction == "credit":
|
| 1259 |
-
credit = _fmt_money(abs(amt))
|
| 1260 |
-
elif direction == "debit":
|
| 1261 |
-
debit = _fmt_money(abs(amt))
|
| 1262 |
-
else:
|
| 1263 |
-
# Conservative fallback for ambiguous rows: preserve as debit
|
| 1264 |
-
# to avoid overstating inflows.
|
| 1265 |
-
debit = _fmt_money(abs(amt))
|
| 1266 |
-
|
| 1267 |
-
balance_out = _fmt_money(bal) if bal is not None else ""
|
| 1268 |
-
new_rows.append([date_txt, desc_txt, credit, debit, balance_out])
|
| 1269 |
-
if bal is not None:
|
| 1270 |
-
prev_bal = bal
|
| 1271 |
-
converted += 1
|
| 1272 |
-
|
| 1273 |
-
if converted < 5:
|
| 1274 |
-
return full
|
| 1275 |
-
|
| 1276 |
-
html_rows = []
|
| 1277 |
-
html_rows.append(
|
| 1278 |
-
"<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"
|
| 1279 |
-
)
|
| 1280 |
-
for rr in new_rows[1:]:
|
| 1281 |
-
html_rows.append(
|
| 1282 |
-
"<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>"
|
| 1283 |
-
)
|
| 1284 |
-
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 1285 |
-
|
| 1286 |
-
return re.sub(
|
| 1287 |
-
r"<table\b[^>]*>.*?</table>",
|
| 1288 |
-
repl_table,
|
| 1289 |
-
md,
|
| 1290 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 1291 |
-
)
|
| 1292 |
|
| 1293 |
|
| 1294 |
-
def
|
| 1295 |
-
"""
|
| 1296 |
-
|
| 1297 |
-
into explicit Credit/Debit columns using generic lexical heuristics.
|
| 1298 |
-
"""
|
| 1299 |
-
if not md or "<table" not in md.lower():
|
| 1300 |
return md
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
|
|
|
|
|
|
| 1309 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1310 |
|
| 1311 |
-
def repl_table(m: re.Match) -> str:
|
| 1312 |
-
full = m.group(0)
|
| 1313 |
-
rows = _extract_rows_plain_from_table(full)
|
| 1314 |
-
if len(rows) < 3:
|
| 1315 |
-
return full
|
| 1316 |
-
hdr = [c.strip().lower() for c in rows[0]]
|
| 1317 |
-
if any("credit" in c for c in hdr) or any("debit" in c for c in hdr):
|
| 1318 |
-
return full
|
| 1319 |
-
amount_idxs = [i for i, c in enumerate(hdr) if "amount" in c]
|
| 1320 |
-
date_idxs = [i for i, c in enumerate(hdr) if "date" in c]
|
| 1321 |
-
desc_idxs = [i for i, c in enumerate(hdr) if "description" in c or "memo" in c or "details" in c]
|
| 1322 |
-
if not amount_idxs or not date_idxs:
|
| 1323 |
-
return full
|
| 1324 |
-
amount_idx = amount_idxs[0]
|
| 1325 |
-
date_idx = date_idxs[0]
|
| 1326 |
-
desc_idx = desc_idxs[0] if desc_idxs else min(1, len(rows[0]) - 1)
|
| 1327 |
-
table_ctx = _cell_plain_text(full)
|
| 1328 |
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1332 |
if not r:
|
| 1333 |
continue
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
|
|
|
| 1341 |
continue
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
| 1356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1357 |
|
| 1358 |
-
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_rows[0]) + "</tr>"]
|
| 1359 |
-
for rr in new_rows[1:]:
|
| 1360 |
-
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
|
| 1361 |
-
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 1362 |
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1369 |
|
| 1370 |
|
| 1371 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1372 |
"""
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
"""
|
| 1376 |
if not md or "<table" not in md.lower():
|
| 1377 |
return md
|
|
|
|
|
|
|
|
|
|
| 1378 |
|
| 1379 |
-
|
| 1380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
rows = _extract_rows_plain_from_table(full)
|
| 1382 |
if len(rows) < 3:
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
hdr = [c.strip() for c in rows[0]]
|
| 1386 |
hdr_l = [h.lower() for h in hdr]
|
| 1387 |
date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
|
| 1388 |
-
desc_i = next(
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
)
|
|
|
|
| 1392 |
credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
|
| 1393 |
debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
|
| 1394 |
-
|
|
|
|
| 1395 |
|
| 1396 |
-
|
| 1397 |
-
|
|
|
|
| 1398 |
|
| 1399 |
max_len = max(len(hdr), max((len(r) for r in rows), default=0))
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
dt = (padded[date_i] or "").strip()
|
| 1405 |
if not _is_date_like(dt):
|
| 1406 |
continue
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
if len(body_rows) < 3:
|
| 1414 |
-
return full
|
| 1415 |
-
|
| 1416 |
-
empty_cd = 0
|
| 1417 |
-
for i, pr in enumerate(body_rows):
|
| 1418 |
-
cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
|
| 1419 |
-
db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
|
| 1420 |
-
if cr is None and db is None:
|
| 1421 |
-
empty_cd += 1
|
| 1422 |
-
if empty_cd < max(3, int(0.6 * len(body_rows))):
|
| 1423 |
-
return full
|
| 1424 |
-
|
| 1425 |
-
new_body: List[List[str]] = []
|
| 1426 |
-
for i, pr in enumerate(body_rows):
|
| 1427 |
-
row = pr[:]
|
| 1428 |
-
cr0 = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
|
| 1429 |
-
db0 = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
|
| 1430 |
-
if cr0 is not None or db0 is not None:
|
| 1431 |
-
new_body.append(row)
|
| 1432 |
-
continue
|
| 1433 |
-
if i == 0 or body_bals[i] is None:
|
| 1434 |
-
new_body.append(row)
|
| 1435 |
-
continue
|
| 1436 |
-
prev_b = body_bals[i - 1]
|
| 1437 |
-
cur_b = body_bals[i]
|
| 1438 |
-
if prev_b is None or cur_b is None:
|
| 1439 |
-
new_body.append(row)
|
| 1440 |
-
continue
|
| 1441 |
-
delta = cur_b - prev_b
|
| 1442 |
-
if abs(delta) < 1e-6:
|
| 1443 |
-
new_body.append(row)
|
| 1444 |
-
continue
|
| 1445 |
-
tol = max(0.55, 0.025 * abs(delta))
|
| 1446 |
-
if abs(delta) <= tol:
|
| 1447 |
-
new_body.append(row)
|
| 1448 |
-
continue
|
| 1449 |
-
if delta > 0:
|
| 1450 |
-
row[credit_i] = _fmt_money(delta)
|
| 1451 |
-
else:
|
| 1452 |
-
row[debit_i] = _fmt_money(-delta)
|
| 1453 |
-
new_body.append(row)
|
| 1454 |
-
|
| 1455 |
-
filled = 0
|
| 1456 |
-
for i, pr in enumerate(new_body):
|
| 1457 |
-
cr = _parse_amount_or_none((pr[credit_i] or "").strip()) if credit_i < len(pr) else None
|
| 1458 |
-
db = _parse_amount_or_none((pr[debit_i] or "").strip()) if debit_i < len(pr) else None
|
| 1459 |
-
if cr is not None or db is not None:
|
| 1460 |
-
filled += 1
|
| 1461 |
-
if filled < max(2, int(0.35 * len(new_body))):
|
| 1462 |
-
return full
|
| 1463 |
-
|
| 1464 |
-
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
|
| 1465 |
-
for pr in new_body:
|
| 1466 |
-
cells = (pr + [""] * (len(hdr) - len(pr)))[: len(hdr)]
|
| 1467 |
-
html_rows.append(
|
| 1468 |
-
"<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in cells) + "</tr>"
|
| 1469 |
)
|
| 1470 |
-
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
|
| 1476 |
-
|
| 1477 |
-
|
| 1478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1479 |
|
| 1480 |
-
|
| 1481 |
-
"""
|
| 1482 |
-
Final defensive cleanup for transaction-like tables:
|
| 1483 |
-
- remove in-body header repeats and subtotal rows
|
| 1484 |
-
- deduplicate repeated rows
|
| 1485 |
-
- correct likely credit/debit direction using row text and balance deltas
|
| 1486 |
-
"""
|
| 1487 |
-
if not md or "<table" not in md.lower():
|
| 1488 |
return md
|
| 1489 |
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
| 1496 |
-
re.IGNORECASE,
|
| 1497 |
-
)
|
| 1498 |
|
| 1499 |
-
|
| 1500 |
-
|
| 1501 |
-
|
| 1502 |
-
|
| 1503 |
-
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
full = m.group(0)
|
| 1510 |
-
rows = _extract_rows_plain_from_table(full)
|
| 1511 |
-
if len(rows) < 2:
|
| 1512 |
-
return full
|
| 1513 |
|
| 1514 |
-
hdr = [
|
| 1515 |
-
|
| 1516 |
-
date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
|
| 1517 |
-
desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
|
| 1518 |
-
credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
|
| 1519 |
-
debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
|
| 1520 |
-
amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h), None)
|
| 1521 |
-
balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
|
| 1522 |
-
is_txn = (date_i is not None) and (desc_i is not None) and (
|
| 1523 |
-
credit_i is not None or debit_i is not None or amount_i is not None
|
| 1524 |
-
)
|
| 1525 |
-
if not is_txn:
|
| 1526 |
-
return full
|
| 1527 |
-
# Without a real balance column, do not rewrite the table (avoids blank balances).
|
| 1528 |
if balance_i is None:
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
debit_i = amount_i
|
| 1539 |
-
amount_i = None
|
| 1540 |
-
if credit_i is None:
|
| 1541 |
-
new_hdr.append("Credit")
|
| 1542 |
-
credit_i = len(new_hdr) - 1
|
| 1543 |
-
if debit_i is None:
|
| 1544 |
-
new_hdr.append("Debit")
|
| 1545 |
-
debit_i = len(new_hdr) - 1
|
| 1546 |
-
|
| 1547 |
-
records = []
|
| 1548 |
-
seen = set()
|
| 1549 |
-
for r in rows[1:]:
|
| 1550 |
-
padded = r + [""] * (len(new_hdr) - len(r))
|
| 1551 |
-
low = [c.strip().lower() for c in padded]
|
| 1552 |
-
joined = " ".join(low)
|
| 1553 |
-
|
| 1554 |
-
# Remove duplicate header rows inside body and subtotal/total lines.
|
| 1555 |
-
if any(
|
| 1556 |
-
x in low
|
| 1557 |
-
for x in ("date", "posting date", "description", "amount", "credit", "debit", "balance")
|
| 1558 |
-
):
|
| 1559 |
-
continue
|
| 1560 |
-
if "subtotal" in joined or joined.startswith("total "):
|
| 1561 |
-
continue
|
| 1562 |
-
|
| 1563 |
-
dt = padded[date_i].strip() if date_i is not None and date_i < len(padded) else ""
|
| 1564 |
-
if not _is_date_like(dt):
|
| 1565 |
-
continue
|
| 1566 |
-
desc = padded[desc_i].strip() if desc_i is not None and desc_i < len(padded) else ""
|
| 1567 |
-
bal = parse_money(padded[balance_i]) if balance_i < len(padded) else None
|
| 1568 |
-
cr = parse_money(padded[credit_i]) if credit_i < len(padded) else None
|
| 1569 |
-
db = parse_money(padded[debit_i]) if debit_i < len(padded) else None
|
| 1570 |
-
|
| 1571 |
-
if cr is None and db is None:
|
| 1572 |
-
continue
|
| 1573 |
-
|
| 1574 |
-
amt = cr if cr is not None else db
|
| 1575 |
-
direction = "credit" if cr is not None else "debit"
|
| 1576 |
-
|
| 1577 |
-
# Row-level lexical correction
|
| 1578 |
-
if amt is not None:
|
| 1579 |
-
if credit_kw.search(desc) and not debit_kw.search(desc):
|
| 1580 |
-
direction = "credit"
|
| 1581 |
-
elif debit_kw.search(desc) and not credit_kw.search(desc):
|
| 1582 |
-
direction = "debit"
|
| 1583 |
-
|
| 1584 |
-
key = (dt, desc, f"{amt:.2f}" if isinstance(amt, (int, float)) else "", f"{bal:.2f}" if isinstance(bal, (int, float)) else "")
|
| 1585 |
-
if key in seen:
|
| 1586 |
-
continue
|
| 1587 |
-
seen.add(key)
|
| 1588 |
-
records.append(
|
| 1589 |
-
{
|
| 1590 |
-
"row": padded[: len(new_hdr)],
|
| 1591 |
-
"amount": abs(amt) if amt is not None else None,
|
| 1592 |
-
"balance": bal,
|
| 1593 |
-
"lex_dir": direction,
|
| 1594 |
-
}
|
| 1595 |
-
)
|
| 1596 |
-
|
| 1597 |
-
if not records:
|
| 1598 |
-
return full
|
| 1599 |
-
|
| 1600 |
-
# Choose balance interpretation direction (forward vs reverse row order)
|
| 1601 |
-
# based on which one produces more valid delta-to-amount matches.
|
| 1602 |
-
def delta_match(delta: float, amt_val: float) -> str:
|
| 1603 |
-
tol = max(0.75, 0.03 * abs(amt_val))
|
| 1604 |
-
if abs(delta - abs(amt_val)) <= tol:
|
| 1605 |
-
return "credit"
|
| 1606 |
-
if abs(delta + abs(amt_val)) <= tol:
|
| 1607 |
-
return "debit"
|
| 1608 |
-
return ""
|
| 1609 |
-
|
| 1610 |
-
fwd_matches = 0
|
| 1611 |
-
rev_matches = 0
|
| 1612 |
-
prev_bal = None
|
| 1613 |
-
for rec in records:
|
| 1614 |
-
bal = rec["balance"]
|
| 1615 |
-
amt_val = rec["amount"]
|
| 1616 |
-
if bal is None or amt_val is None:
|
| 1617 |
continue
|
| 1618 |
-
if
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
-
|
| 1631 |
-
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
-
|
| 1638 |
-
|
| 1639 |
-
direction = d
|
| 1640 |
-
if bal is not None:
|
| 1641 |
-
prev_bal = bal
|
| 1642 |
-
|
| 1643 |
-
out[credit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "credit") else ""
|
| 1644 |
-
out[debit_i] = fmt_money(amt_val) if (amt_val is not None and direction == "debit") else ""
|
| 1645 |
-
out[balance_i] = f"{bal:,.2f}" if bal is not None else out[balance_i]
|
| 1646 |
-
body.append(out)
|
| 1647 |
-
|
| 1648 |
-
if not body:
|
| 1649 |
-
return full
|
| 1650 |
-
html_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in new_hdr) + "</tr>"]
|
| 1651 |
-
for rr in body:
|
| 1652 |
-
html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in rr) + "</tr>")
|
| 1653 |
-
return "<table>\n" + "\n".join(html_rows) + "\n</table>"
|
| 1654 |
|
| 1655 |
-
|
| 1656 |
-
|
| 1657 |
-
repl_table,
|
| 1658 |
-
md,
|
| 1659 |
-
flags=re.IGNORECASE | re.DOTALL,
|
| 1660 |
-
)
|
| 1661 |
|
| 1662 |
|
| 1663 |
def _extract_daily_balance_by_date(md: str):
|
|
@@ -1705,333 +1175,6 @@ def _extract_daily_balance_by_date(md: str):
|
|
| 1705 |
return out
|
| 1706 |
|
| 1707 |
|
| 1708 |
-
def enrich_transaction_tables_with_daily_balances(md: str) -> str:
|
| 1709 |
-
"""
|
| 1710 |
-
If transaction tables have Date+Amount but no Balance column, append a Balance
|
| 1711 |
-
column using date-matched daily ending balances from the same document.
|
| 1712 |
-
"""
|
| 1713 |
-
if not md or "<table" not in md.lower():
|
| 1714 |
-
return md
|
| 1715 |
-
dmap = _extract_daily_balance_by_date(md)
|
| 1716 |
-
if not dmap:
|
| 1717 |
-
return md
|
| 1718 |
-
|
| 1719 |
-
def repl(m: re.Match) -> str:
|
| 1720 |
-
table = m.group(0)
|
| 1721 |
-
if re.search(r"(?:colspan|rowspan)\s*=", table, flags=re.IGNORECASE):
|
| 1722 |
-
return table
|
| 1723 |
-
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", table, flags=re.IGNORECASE | re.DOTALL))
|
| 1724 |
-
if len(tr_blocks) < 2:
|
| 1725 |
-
return table
|
| 1726 |
-
first = tr_blocks[0].group(0)
|
| 1727 |
-
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", first, flags=re.IGNORECASE | re.DOTALL)
|
| 1728 |
-
if not h_inner_m:
|
| 1729 |
-
return table
|
| 1730 |
-
h_inner = h_inner_m.group(1)
|
| 1731 |
-
h_cells = list(_CELL.finditer(h_inner))
|
| 1732 |
-
if not h_cells:
|
| 1733 |
-
return table
|
| 1734 |
-
hdr_txt = [_cell_plain_text(c.group(0)).strip().lower() for c in h_cells]
|
| 1735 |
-
if any("balance" in c for c in hdr_txt):
|
| 1736 |
-
return table
|
| 1737 |
-
if not any("date" in c for c in hdr_txt):
|
| 1738 |
-
return table
|
| 1739 |
-
if not any(("amount" in c) or ("debit" in c) or ("credit" in c) for c in hdr_txt):
|
| 1740 |
-
return table
|
| 1741 |
-
date_idx = next((i for i, c in enumerate(hdr_txt) if "date" in c), -1)
|
| 1742 |
-
if date_idx < 0:
|
| 1743 |
-
return table
|
| 1744 |
-
|
| 1745 |
-
new_parts: List[str] = []
|
| 1746 |
-
cursor = 0
|
| 1747 |
-
added = 0
|
| 1748 |
-
for i, trm in enumerate(tr_blocks):
|
| 1749 |
-
new_parts.append(table[cursor : trm.start()])
|
| 1750 |
-
tr_seg = trm.group(0)
|
| 1751 |
-
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
|
| 1752 |
-
if not inner_m:
|
| 1753 |
-
new_parts.append(tr_seg)
|
| 1754 |
-
cursor = trm.end()
|
| 1755 |
-
continue
|
| 1756 |
-
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
|
| 1757 |
-
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
|
| 1758 |
-
tr_close = "</tr>"
|
| 1759 |
-
inner = inner_m.group(1)
|
| 1760 |
-
cells = [c.group(0) for c in _CELL.finditer(inner)]
|
| 1761 |
-
if i == 0:
|
| 1762 |
-
new_inner = inner + "<th>Balance</th>"
|
| 1763 |
-
new_parts.append(tr_open + new_inner + tr_close)
|
| 1764 |
-
else:
|
| 1765 |
-
if date_idx >= len(cells):
|
| 1766 |
-
new_parts.append(tr_seg)
|
| 1767 |
-
cursor = trm.end()
|
| 1768 |
-
continue
|
| 1769 |
-
d_txt = _cell_plain_text(cells[date_idx])
|
| 1770 |
-
dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or "")
|
| 1771 |
-
bal = dmap.get(dm.group(0)) if dm else None
|
| 1772 |
-
if bal is None and dm:
|
| 1773 |
-
bal = dmap.get("/".join(dm.group(0).split("/")[:2]))
|
| 1774 |
-
if bal is None:
|
| 1775 |
-
new_parts.append(tr_seg)
|
| 1776 |
-
cursor = trm.end()
|
| 1777 |
-
continue
|
| 1778 |
-
new_inner = inner + f"<td>{bal:,.2f}</td>"
|
| 1779 |
-
new_parts.append(tr_open + new_inner + tr_close)
|
| 1780 |
-
added += 1
|
| 1781 |
-
cursor = trm.end()
|
| 1782 |
-
new_parts.append(table[cursor:])
|
| 1783 |
-
if added < 3:
|
| 1784 |
-
return table
|
| 1785 |
-
return "".join(new_parts)
|
| 1786 |
-
|
| 1787 |
-
return re.sub(r"<table\b[^>]*>.*?</table>", repl, md, flags=re.IGNORECASE | re.DOTALL)
|
| 1788 |
-
|
| 1789 |
-
|
| 1790 |
-
def _parse_statement_edge_balances(md: str):
|
| 1791 |
-
start = None
|
| 1792 |
-
end = None
|
| 1793 |
-
m1 = re.search(
|
| 1794 |
-
r"(?:Beginning|Starting)\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 1795 |
-
md,
|
| 1796 |
-
flags=re.IGNORECASE,
|
| 1797 |
-
)
|
| 1798 |
-
if m1:
|
| 1799 |
-
start = _parse_amount_or_none(m1.group(1))
|
| 1800 |
-
m2 = re.search(
|
| 1801 |
-
r"Ending\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 1802 |
-
md,
|
| 1803 |
-
flags=re.IGNORECASE,
|
| 1804 |
-
)
|
| 1805 |
-
if m2:
|
| 1806 |
-
end = _parse_amount_or_none(m2.group(1))
|
| 1807 |
-
return start, end
|
| 1808 |
-
|
| 1809 |
-
|
| 1810 |
-
def synthesize_running_balance_columns(md: str) -> str:
|
| 1811 |
-
"""
|
| 1812 |
-
Fallback when no explicit balance column exists: infer running balances from
|
| 1813 |
-
statement beginning/ending balance and signed transaction amounts.
|
| 1814 |
-
"""
|
| 1815 |
-
if not md or "<table" not in md.lower():
|
| 1816 |
-
return md
|
| 1817 |
-
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 1818 |
-
if start_bal is None and end_bal is None:
|
| 1819 |
-
return md
|
| 1820 |
-
|
| 1821 |
-
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
|
| 1822 |
-
tr_re = re.compile(r"<tr\b[^>]*>.*?</tr>", re.IGNORECASE | re.DOTALL)
|
| 1823 |
-
|
| 1824 |
-
tables = list(table_re.finditer(md))
|
| 1825 |
-
row_refs = []
|
| 1826 |
-
amounts = []
|
| 1827 |
-
headers = {}
|
| 1828 |
-
|
| 1829 |
-
for ti, tm in enumerate(tables):
|
| 1830 |
-
t = tm.group(0)
|
| 1831 |
-
if re.search(r"(?:colspan|rowspan)\s*=", t, flags=re.IGNORECASE):
|
| 1832 |
-
continue
|
| 1833 |
-
trs = list(tr_re.finditer(t))
|
| 1834 |
-
if len(trs) < 2:
|
| 1835 |
-
continue
|
| 1836 |
-
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trs[0].group(0), flags=re.IGNORECASE | re.DOTALL)
|
| 1837 |
-
if not h_inner_m:
|
| 1838 |
-
continue
|
| 1839 |
-
h_cells = [c.group(0) for c in _CELL.finditer(h_inner_m.group(1))]
|
| 1840 |
-
if not h_cells:
|
| 1841 |
-
continue
|
| 1842 |
-
htxt = [_cell_plain_text(c).strip().lower() for c in h_cells]
|
| 1843 |
-
if any("balance" in h for h in htxt):
|
| 1844 |
-
continue
|
| 1845 |
-
date_idx = next((i for i, h in enumerate(htxt) if "date" in h), -1)
|
| 1846 |
-
amt_idx = next((i for i, h in enumerate(htxt) if "amount" in h or "debit" in h or "credit" in h), -1)
|
| 1847 |
-
if date_idx < 0 or amt_idx < 0:
|
| 1848 |
-
continue
|
| 1849 |
-
headers[ti] = (date_idx, amt_idx)
|
| 1850 |
-
for ri, trm in enumerate(trs[1:], 1):
|
| 1851 |
-
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trm.group(0), flags=re.IGNORECASE | re.DOTALL)
|
| 1852 |
-
if not inner_m:
|
| 1853 |
-
continue
|
| 1854 |
-
cells = [c.group(0) for c in _CELL.finditer(inner_m.group(1))]
|
| 1855 |
-
if len(cells) <= max(date_idx, amt_idx):
|
| 1856 |
-
continue
|
| 1857 |
-
d_txt = _cell_plain_text(cells[date_idx])
|
| 1858 |
-
if not re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or ""):
|
| 1859 |
-
continue
|
| 1860 |
-
amt = _parse_amount_or_none(_cell_plain_text(cells[amt_idx]))
|
| 1861 |
-
if amt is None:
|
| 1862 |
-
continue
|
| 1863 |
-
row_refs.append((ti, ri))
|
| 1864 |
-
amounts.append(amt)
|
| 1865 |
-
|
| 1866 |
-
if len(amounts) < 20:
|
| 1867 |
-
return md
|
| 1868 |
-
if start_bal is None:
|
| 1869 |
-
start_bal = (end_bal or 0.0) - sum(amounts)
|
| 1870 |
-
|
| 1871 |
-
running = []
|
| 1872 |
-
cur = float(start_bal)
|
| 1873 |
-
for a in amounts:
|
| 1874 |
-
cur += float(a)
|
| 1875 |
-
running.append(cur)
|
| 1876 |
-
bal_by_ref = {row_refs[i]: running[i] for i in range(len(row_refs))}
|
| 1877 |
-
|
| 1878 |
-
pieces = []
|
| 1879 |
-
last = 0
|
| 1880 |
-
for ti, tm in enumerate(tables):
|
| 1881 |
-
pieces.append(md[last : tm.start()])
|
| 1882 |
-
t = tm.group(0)
|
| 1883 |
-
if ti not in headers:
|
| 1884 |
-
pieces.append(t)
|
| 1885 |
-
last = tm.end()
|
| 1886 |
-
continue
|
| 1887 |
-
date_idx, amt_idx = headers[ti]
|
| 1888 |
-
trs = list(tr_re.finditer(t))
|
| 1889 |
-
out_t = []
|
| 1890 |
-
cur2 = 0
|
| 1891 |
-
for idx, trm in enumerate(trs):
|
| 1892 |
-
out_t.append(t[cur2 : trm.start()])
|
| 1893 |
-
tr_seg = trm.group(0)
|
| 1894 |
-
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
|
| 1895 |
-
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
|
| 1896 |
-
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
|
| 1897 |
-
if not inner_m:
|
| 1898 |
-
out_t.append(tr_seg)
|
| 1899 |
-
cur2 = trm.end()
|
| 1900 |
-
continue
|
| 1901 |
-
inner = inner_m.group(1)
|
| 1902 |
-
if idx == 0:
|
| 1903 |
-
out_t.append(tr_open + inner + "<th>Balance</th></tr>")
|
| 1904 |
-
else:
|
| 1905 |
-
b = bal_by_ref.get((ti, idx))
|
| 1906 |
-
if b is None:
|
| 1907 |
-
out_t.append(tr_seg)
|
| 1908 |
-
else:
|
| 1909 |
-
out_t.append(tr_open + inner + f"<td>{b:,.2f}</td></tr>")
|
| 1910 |
-
cur2 = trm.end()
|
| 1911 |
-
out_t.append(t[cur2:])
|
| 1912 |
-
pieces.append("".join(out_t))
|
| 1913 |
-
last = tm.end()
|
| 1914 |
-
pieces.append(md[last:])
|
| 1915 |
-
return "".join(pieces)
|
| 1916 |
-
|
| 1917 |
-
|
| 1918 |
-
def _extract_txn_date_balance_pairs(md: str):
|
| 1919 |
-
pairs = []
|
| 1920 |
-
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
|
| 1921 |
-
t = tm.group(0)
|
| 1922 |
-
rows = _extract_rows_plain_from_table(t)
|
| 1923 |
-
if len(rows) < 2:
|
| 1924 |
-
continue
|
| 1925 |
-
hdr = [c.strip().lower() for c in rows[0]]
|
| 1926 |
-
if not hdr:
|
| 1927 |
-
continue
|
| 1928 |
-
d_i = next((i for i, h in enumerate(hdr) if "date" in h), -1)
|
| 1929 |
-
b_i = next((i for i, h in enumerate(hdr) if "balance" in h), -1)
|
| 1930 |
-
a_i = next((i for i, h in enumerate(hdr) if "amount" in h or "debit" in h or "credit" in h), -1)
|
| 1931 |
-
if d_i < 0 or b_i < 0 or a_i < 0:
|
| 1932 |
-
continue
|
| 1933 |
-
for r in rows[1:]:
|
| 1934 |
-
if len(r) <= max(d_i, b_i):
|
| 1935 |
-
continue
|
| 1936 |
-
dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", (r[d_i] or ""))
|
| 1937 |
-
if not dm:
|
| 1938 |
-
continue
|
| 1939 |
-
bal = _parse_amount_or_none(r[b_i])
|
| 1940 |
-
if bal is None:
|
| 1941 |
-
continue
|
| 1942 |
-
pairs.append((dm.group(0), bal))
|
| 1943 |
-
return pairs
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
-
def augment_with_derived_balance_views(md: str) -> str:
|
| 1947 |
-
"""
|
| 1948 |
-
Add a compact derived daily balance table and inferred beginning/ending summary
|
| 1949 |
-
when the document lacks a robust explicit daily balance section.
|
| 1950 |
-
"""
|
| 1951 |
-
if not md:
|
| 1952 |
-
return md
|
| 1953 |
-
# If there is already a clear daily-balance section, keep output unchanged.
|
| 1954 |
-
if re.search(r"daily\s+(?:ledger\s+)?balances", md, flags=re.IGNORECASE):
|
| 1955 |
-
return md
|
| 1956 |
-
|
| 1957 |
-
pairs = _extract_txn_date_balance_pairs(md)
|
| 1958 |
-
if len(pairs) < 12:
|
| 1959 |
-
return md
|
| 1960 |
-
|
| 1961 |
-
by_day = {}
|
| 1962 |
-
for d, b in pairs:
|
| 1963 |
-
k = "/".join(d.split("/")[:2])
|
| 1964 |
-
by_day[k] = b
|
| 1965 |
-
if len(by_day) < 8:
|
| 1966 |
-
return md
|
| 1967 |
-
|
| 1968 |
-
days = sorted(
|
| 1969 |
-
by_day.keys(),
|
| 1970 |
-
key=lambda x: (int(x.split("/")[0]), int(x.split("/")[1])),
|
| 1971 |
-
)
|
| 1972 |
-
begin = by_day[days[0]]
|
| 1973 |
-
end = by_day[days[-1]]
|
| 1974 |
-
|
| 1975 |
-
has_begin = bool(re.search(r"(?:beginning|starting)\s+balance", md, flags=re.IGNORECASE))
|
| 1976 |
-
has_end = bool(re.search(r"ending\s+balance", md, flags=re.IGNORECASE))
|
| 1977 |
-
|
| 1978 |
-
rows = ["<tr><th>Date</th><th>Balance</th></tr>"]
|
| 1979 |
-
for d in days:
|
| 1980 |
-
rows.append(f"<tr><td>{html.escape(d)}</td><td>{by_day[d]:,.2f}</td></tr>")
|
| 1981 |
-
daily_tbl = "<table>\n" + "\n".join(rows) + "\n</table>"
|
| 1982 |
-
|
| 1983 |
-
extra = ["", "<div align=\"center\">Derived daily balances</div>", daily_tbl]
|
| 1984 |
-
if not has_begin or not has_end:
|
| 1985 |
-
extra.insert(
|
| 1986 |
-
0,
|
| 1987 |
-
(
|
| 1988 |
-
f"Inferred beginning balance: {begin:,.2f}\n"
|
| 1989 |
-
f"Inferred ending balance: {end:,.2f}"
|
| 1990 |
-
),
|
| 1991 |
-
)
|
| 1992 |
-
return md.rstrip() + "\n\n" + "\n\n".join(extra) + "\n"
|
| 1993 |
-
|
| 1994 |
-
|
| 1995 |
-
def prepend_balance_snapshot(md: str) -> str:
|
| 1996 |
-
"""
|
| 1997 |
-
Add a compact, explicit balance snapshot near the top so downstream metadata
|
| 1998 |
-
extraction consistently captures beginning/ending balances.
|
| 1999 |
-
"""
|
| 2000 |
-
if not md:
|
| 2001 |
-
return md
|
| 2002 |
-
if "balance snapshot" in md.lower():
|
| 2003 |
-
return md
|
| 2004 |
-
|
| 2005 |
-
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 2006 |
-
if start_bal is None and end_bal is None:
|
| 2007 |
-
# Try account-summary table fallback
|
| 2008 |
-
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
|
| 2009 |
-
rows = _extract_rows_plain_from_table(tm.group(0))
|
| 2010 |
-
for r in rows:
|
| 2011 |
-
if len(r) < 2:
|
| 2012 |
-
continue
|
| 2013 |
-
key = (r[0] or "").strip().lower()
|
| 2014 |
-
val = _parse_amount_or_none(r[1] if len(r) > 1 else "")
|
| 2015 |
-
if val is None:
|
| 2016 |
-
continue
|
| 2017 |
-
if start_bal is None and ("beginning balance" in key or "starting balance" in key):
|
| 2018 |
-
start_bal = val
|
| 2019 |
-
if end_bal is None and "ending balance" in key:
|
| 2020 |
-
end_bal = val
|
| 2021 |
-
if start_bal is not None or end_bal is not None:
|
| 2022 |
-
break
|
| 2023 |
-
if start_bal is None and end_bal is None:
|
| 2024 |
-
return md
|
| 2025 |
-
|
| 2026 |
-
lines = ["## Balance Snapshot"]
|
| 2027 |
-
if start_bal is not None:
|
| 2028 |
-
lines.append(f"Beginning balance: {start_bal:,.2f}")
|
| 2029 |
-
if end_bal is not None:
|
| 2030 |
-
lines.append(f"Ending balance: {end_bal:,.2f}")
|
| 2031 |
-
header = "\n".join(lines)
|
| 2032 |
-
return header + "\n\n" + md
|
| 2033 |
-
|
| 2034 |
-
|
| 2035 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
| 2036 |
import pymupdf as fitz
|
| 2037 |
from PIL import Image
|
|
@@ -2058,44 +1201,10 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
|
| 2058 |
canvas.paste(img, (pad_l, pad_t))
|
| 2059 |
img = canvas
|
| 2060 |
|
| 2061 |
-
#
|
| 2062 |
-
|
| 2063 |
-
|
| 2064 |
-
total_px = iw * ih
|
| 2065 |
-
if long_side > MAX_PAGE_LONG_SIDE or total_px > MAX_PAGE_TOTAL_PIXELS:
|
| 2066 |
-
scale_long = MAX_PAGE_LONG_SIDE / float(long_side)
|
| 2067 |
-
scale_area = (MAX_PAGE_TOTAL_PIXELS / float(total_px)) ** 0.5
|
| 2068 |
-
scale = min(scale_long, scale_area, 1.0)
|
| 2069 |
-
nw = max(900, int(iw * scale))
|
| 2070 |
-
nh = max(900, int(ih * scale))
|
| 2071 |
-
img = img.resize((nw, nh), Image.Resampling.LANCZOS)
|
| 2072 |
-
|
| 2073 |
-
base_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}")
|
| 2074 |
-
img_path = f"{base_path}.png"
|
| 2075 |
img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
|
| 2076 |
-
try:
|
| 2077 |
-
size_bytes = os.path.getsize(img_path)
|
| 2078 |
-
except OSError:
|
| 2079 |
-
size_bytes = 0
|
| 2080 |
-
if size_bytes > MAX_PAGE_UPLOAD_BYTES:
|
| 2081 |
-
jpg_path = f"{base_path}.jpg"
|
| 2082 |
-
quality = PAGE_JPEG_QUALITY
|
| 2083 |
-
work = img
|
| 2084 |
-
for _ in range(5):
|
| 2085 |
-
work.save(jpg_path, "JPEG", quality=quality, optimize=True)
|
| 2086 |
-
try:
|
| 2087 |
-
jsize = os.path.getsize(jpg_path)
|
| 2088 |
-
except OSError:
|
| 2089 |
-
jsize = 0
|
| 2090 |
-
if 0 < jsize <= MAX_PAGE_UPLOAD_BYTES:
|
| 2091 |
-
img_path = jpg_path
|
| 2092 |
-
break
|
| 2093 |
-
quality = max(68, quality - 6)
|
| 2094 |
-
nw = max(900, int(work.width * 0.93))
|
| 2095 |
-
nh = max(900, int(work.height * 0.93))
|
| 2096 |
-
work = work.resize((nw, nh), Image.Resampling.LANCZOS)
|
| 2097 |
-
else:
|
| 2098 |
-
img_path = jpg_path
|
| 2099 |
page_images.append(img_path)
|
| 2100 |
page_heights.append(img.height)
|
| 2101 |
|
|
@@ -2204,17 +1313,6 @@ def run_ocr(uploaded_file):
|
|
| 2204 |
|
| 2205 |
merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
|
| 2206 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
| 2207 |
-
merged = strip_malformed_leading_table_rows(merged)
|
| 2208 |
-
merged = strip_subtotal_rows_from_transaction_tables(merged)
|
| 2209 |
-
merged = mask_non_monetary_long_numbers(merged)
|
| 2210 |
-
merged = normalize_ambiguous_amount_balance_tables(merged)
|
| 2211 |
-
merged = normalize_amount_only_transaction_tables(merged)
|
| 2212 |
-
merged = infer_credit_debit_from_balance_deltas(merged)
|
| 2213 |
-
merged = sanitize_transaction_tables(merged)
|
| 2214 |
-
merged = stabilize_table_markup(merged, rounds=4)
|
| 2215 |
-
merged = enrich_transaction_tables_with_daily_balances(merged)
|
| 2216 |
-
# Keep transaction amounts untouched; add explicit snapshot for metadata extraction.
|
| 2217 |
-
merged = prepend_balance_snapshot(merged)
|
| 2218 |
merged = stabilize_table_markup(merged, rounds=2)
|
| 2219 |
merged = repair_thead_cell_semantics(merged)
|
| 2220 |
merged = strip_degenerate_html_tables(merged)
|
|
@@ -2229,11 +1327,7 @@ def run_ocr(uploaded_file):
|
|
| 2229 |
finally:
|
| 2230 |
for p in page_images:
|
| 2231 |
try:
|
| 2232 |
-
if (
|
| 2233 |
-
isinstance(p, str)
|
| 2234 |
-
and "glmocr_page_" in os.path.basename(p)
|
| 2235 |
-
and (p.endswith(".png") or p.endswith(".jpg") or p.endswith(".jpeg"))
|
| 2236 |
-
):
|
| 2237 |
os.unlink(p)
|
| 2238 |
except Exception:
|
| 2239 |
pass
|
|
|
|
| 26 |
but do not disable an entire table. Stabilization runs in multiple passes.
|
| 27 |
- Split single cells that clearly contain transaction amount + trailing balance
|
| 28 |
(two money tokens, tight gap) into two cells so classifiers can see a balance column.
|
|
|
|
|
|
|
| 29 |
- thead uses th only; degenerate empty/sparse non-financial tables are dropped.
|
| 30 |
|
| 31 |
Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
|
|
|
|
| 53 |
import os
|
| 54 |
import re
|
| 55 |
import tempfile
|
| 56 |
+
import uuid
|
| 57 |
from collections import Counter
|
| 58 |
from typing import List, Optional, Tuple
|
| 59 |
|
|
|
|
| 85 |
|
| 86 |
# Rasterization: higher scale = more pixels per PDF point (helps small type,
|
| 87 |
# boxed headers, and narrow columns). Same constant for all uploads.
|
| 88 |
+
RENDER_SCALE = 3.0
|
| 89 |
|
| 90 |
# White margin as a fraction of page width/height after render. Extra right
|
| 91 |
# margin helps right-aligned currency columns that hug the page edge.
|
| 92 |
PAD_LEFT_FRAC = 0.035
|
| 93 |
+
PAD_RIGHT_FRAC = 0.10
|
| 94 |
PAD_TOP_FRAC = 0.018
|
| 95 |
PAD_BOTTOM_FRAC = 0.018
|
| 96 |
|
|
|
|
| 117 |
PAGE_PNG_COMPRESS_LEVEL = 3
|
| 118 |
# JPEG quality for small header/footer crops sent to the API.
|
| 119 |
ZONE_JPEG_QUALITY = 95
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
MIN_PDF_TEXT_CHARS_NATIVE_LAYER = 1500
|
| 122 |
|
|
|
|
| 163 |
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 164 |
config = yaml.safe_load(f)
|
| 165 |
config.setdefault("pipeline", {}).setdefault("maas", {})
|
| 166 |
+
config["pipeline"]["maas"]["enabled"] = True
|
| 167 |
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
|
| 168 |
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
| 169 |
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
|
|
| 737 |
_GLUED_HEADER_ROW_RE = re.compile(r"date\d{1,2}/\d{1,2}|typedeposit|amount\d", re.IGNORECASE)
|
| 738 |
|
| 739 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
def looks_like_markdown_table(block: str) -> bool:
|
| 741 |
lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
|
| 742 |
if len(lines) < 2:
|
|
|
|
| 794 |
|
| 795 |
|
| 796 |
def light_stabilize_markdown(page_md: str) -> str:
|
| 797 |
+
"""Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; light table pass."""
|
| 798 |
if not page_md:
|
| 799 |
return page_md
|
| 800 |
page_md = normalize_money_glyphs(page_md)
|
|
|
|
| 804 |
if looks_like_markdown_table(b):
|
| 805 |
out_blocks.append(md_table_to_html(b))
|
| 806 |
else:
|
| 807 |
+
out_blocks.append(b)
|
| 808 |
+
merged = close_unclosed_html('\n\n'.join(out_blocks))
|
|
|
|
|
|
|
|
|
|
| 809 |
merged = repair_thead_cell_semantics(merged)
|
| 810 |
+
merged = stabilize_table_markup(merged, rounds=2)
|
| 811 |
merged = strip_degenerate_html_tables(merged)
|
| 812 |
return merged
|
| 813 |
|
| 814 |
|
| 815 |
def _parse_amount_or_none(s: str):
|
| 816 |
+
raw = (s or "").strip()
|
| 817 |
+
if not raw:
|
| 818 |
+
return None
|
| 819 |
+
if re.search(r"\d{10,}", raw) and "." not in raw and "," not in raw:
|
| 820 |
return None
|
| 821 |
+
t = raw
|
| 822 |
t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
|
| 823 |
t = t.replace("−", "-").replace("–", "-").replace("—", "-")
|
| 824 |
if not re.search(r"\d", t):
|
| 825 |
return None
|
| 826 |
+
if "." not in t and len(re.sub(r"[^\d]", "", t)) >= 8:
|
| 827 |
+
return None
|
| 828 |
try:
|
| 829 |
+
v = float(t)
|
| 830 |
+
# Guardrail: reject clearly implausible values (OCR-glued IDs/garbage),
|
| 831 |
+
# which can explode statement-level reconciliation arithmetic.
|
| 832 |
+
if abs(v) > 10_000_000:
|
| 833 |
+
return None
|
| 834 |
+
return v
|
| 835 |
except Exception:
|
| 836 |
return None
|
| 837 |
|
|
|
|
| 855 |
|
| 856 |
def _is_date_like(s: str) -> bool:
|
| 857 |
t = (s or "").strip()
|
| 858 |
+
if not t:
|
| 859 |
+
return False
|
| 860 |
+
if re.match(r"^(?:\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)$", t):
|
| 861 |
+
return True
|
| 862 |
+
if re.match(r"^\d{4}[/-]\d{1,2}[/-]\d{1,2}$", t):
|
| 863 |
+
return True
|
| 864 |
+
return bool(
|
| 865 |
+
re.match(
|
| 866 |
+
r"^(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})$",
|
| 867 |
+
t,
|
| 868 |
+
re.I,
|
| 869 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
)
|
| 871 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
|
| 873 |
+
def infer_credit_debit_from_balance_deltas(md: str) -> str:
|
| 874 |
+
# Intentionally disabled in the simplified app.
|
| 875 |
+
return md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
|
| 877 |
|
| 878 |
+
def normalize_balance_snapshot(md: str) -> str:
|
| 879 |
+
"""Replace noisy/incorrect snapshot text with values parsed from statement summary."""
|
| 880 |
+
if not md:
|
|
|
|
|
|
|
|
|
|
| 881 |
return md
|
| 882 |
+
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 883 |
+
if start_bal is None and end_bal is None:
|
| 884 |
+
return md
|
| 885 |
+
# Remove any existing markdown snapshot block at document start.
|
| 886 |
+
md2 = re.sub(
|
| 887 |
+
r"^\s*##\s*Balance\s+Snapshot\s*\n(?:[^\n]*\n){0,8}\s*",
|
| 888 |
+
"",
|
| 889 |
+
md,
|
| 890 |
+
count=1,
|
| 891 |
+
flags=re.IGNORECASE,
|
| 892 |
)
|
| 893 |
+
lines = ["## Balance Snapshot"]
|
| 894 |
+
if start_bal is not None:
|
| 895 |
+
lines.append(f"Beginning balance: {start_bal:,.2f}")
|
| 896 |
+
if end_bal is not None:
|
| 897 |
+
lines.append(f"Ending balance: {end_bal:,.2f}")
|
| 898 |
+
return "\n".join(lines) + "\n\n" + md2.lstrip()
|
| 899 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
|
| 901 |
+
def _parse_statement_edge_balances(md: str) -> Tuple[Optional[float], Optional[float]]:
|
| 902 |
+
"""Extract statement beginning/ending balances using generic wording patterns."""
|
| 903 |
+
# Prefer account-summary table rows (most reliable for statements).
|
| 904 |
+
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
|
| 905 |
+
tbl = tm.group(0)
|
| 906 |
+
plain_tbl = _cell_plain_text(tbl).lower()
|
| 907 |
+
if "account summary" not in plain_tbl:
|
| 908 |
+
continue
|
| 909 |
+
start = None
|
| 910 |
+
end = None
|
| 911 |
+
rows = _extract_rows_plain_from_table(tbl)
|
| 912 |
+
for r in rows:
|
| 913 |
if not r:
|
| 914 |
continue
|
| 915 |
+
key = " ".join((c or "").strip().lower() for c in r[:2])
|
| 916 |
+
amts = []
|
| 917 |
+
for c in r:
|
| 918 |
+
for m in re.finditer(r"-?\d{1,3}(?:,\d{3})*(?:\.\d{2})", c or ""):
|
| 919 |
+
v = _parse_amount_or_none(m.group(0))
|
| 920 |
+
if v is not None:
|
| 921 |
+
amts.append(v)
|
| 922 |
+
if not amts:
|
| 923 |
continue
|
| 924 |
+
if start is None and ("beginning balance" in key or "starting balance" in key):
|
| 925 |
+
start = amts[0]
|
| 926 |
+
if end is None and "ending balance" in key:
|
| 927 |
+
end = amts[-1]
|
| 928 |
+
if start is not None or end is not None:
|
| 929 |
+
return start, end
|
| 930 |
+
|
| 931 |
+
# Fallback: free-text scan with all candidates, choose the largest magnitude match.
|
| 932 |
+
# Additional fallback forms common in statements.
|
| 933 |
+
start_cands = []
|
| 934 |
+
for m in re.finditer(
|
| 935 |
+
r"(?:Balance\s+Forward(?:\s+From)?|Beginning\s+balance\s+on)\s*[^\n$]{0,60}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\+?",
|
| 936 |
+
md,
|
| 937 |
+
flags=re.IGNORECASE,
|
| 938 |
+
):
|
| 939 |
+
v = _parse_amount_or_none(m.group(1))
|
| 940 |
+
if v is not None:
|
| 941 |
+
start_cands.append(v)
|
| 942 |
+
for m in re.finditer(
|
| 943 |
+
r"(?:Beginning|Starting)\s+balance[^$\n]{0,120}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 944 |
+
md,
|
| 945 |
+
flags=re.IGNORECASE,
|
| 946 |
+
):
|
| 947 |
+
v = _parse_amount_or_none(m.group(1))
|
| 948 |
+
if v is not None:
|
| 949 |
+
start_cands.append(v)
|
| 950 |
+
end_cands = []
|
| 951 |
+
for m in re.finditer(
|
| 952 |
+
r"Ending\s+balance(?:\s+on)?[^\n$]{0,60}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\+?",
|
| 953 |
+
md,
|
| 954 |
+
flags=re.IGNORECASE,
|
| 955 |
+
):
|
| 956 |
+
v = _parse_amount_or_none(m.group(1))
|
| 957 |
+
if v is not None:
|
| 958 |
+
end_cands.append(v)
|
| 959 |
+
for m in re.finditer(
|
| 960 |
+
r"Ending\s+balance[^$\n]{0,120}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 961 |
+
md,
|
| 962 |
+
flags=re.IGNORECASE,
|
| 963 |
+
):
|
| 964 |
+
v = _parse_amount_or_none(m.group(1))
|
| 965 |
+
if v is not None:
|
| 966 |
+
end_cands.append(v)
|
| 967 |
+
start = max(start_cands, key=lambda x: abs(x)) if start_cands else None
|
| 968 |
+
end = max(end_cands, key=lambda x: abs(x)) if end_cands else None
|
| 969 |
+
return start, end
|
| 970 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
|
| 972 |
+
def _table_sign_bias(ctx: str) -> int:
|
| 973 |
+
"""Estimate sign direction for amount-only tables from local context."""
|
| 974 |
+
c = (ctx or "").lower()
|
| 975 |
+
if re.search(r"\b(deposit|deposits|credit|credits|other credits|rtp\s*rcvd|money\s+in)\b", c):
|
| 976 |
+
return 1
|
| 977 |
+
if re.search(
|
| 978 |
+
r"\b(payment|payments|withdrawal|withdrawals|debit|debits|checks?\s+paid|service\s+charge|fee|fees|money\s+out)\b",
|
| 979 |
+
c,
|
| 980 |
+
):
|
| 981 |
+
return -1
|
| 982 |
+
return 0
|
| 983 |
|
| 984 |
|
| 985 |
+
def _signed_amount_from_row(desc: str, amount: Optional[float], table_bias: int) -> Optional[float]:
|
| 986 |
+
if amount is None:
|
| 987 |
+
return None
|
| 988 |
+
d = (desc or "").lower()
|
| 989 |
+
if re.search(r"\b(deposit|credit|recd|received|refund|interest|rtp\s*rcvd)\b", d):
|
| 990 |
+
return abs(amount)
|
| 991 |
+
if re.search(r"\b(payment|withdraw|debit|purchase|fee|charge|check|ach)\b", d):
|
| 992 |
+
return -abs(amount)
|
| 993 |
+
if table_bias > 0:
|
| 994 |
+
return abs(amount)
|
| 995 |
+
if table_bias < 0:
|
| 996 |
+
return -abs(amount)
|
| 997 |
+
return None
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
def synthesize_running_balances(md: str) -> str:
|
| 1001 |
"""
|
| 1002 |
+
For transaction-like tables lacking a Balance column, synthesize running balances
|
| 1003 |
+
from statement beginning balance and signed amounts. Generic, pattern-based only.
|
| 1004 |
"""
|
| 1005 |
if not md or "<table" not in md.lower():
|
| 1006 |
return md
|
| 1007 |
+
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 1008 |
+
if start_bal is None:
|
| 1009 |
+
return md
|
| 1010 |
|
| 1011 |
+
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
|
| 1012 |
+
table_matches = list(table_re.finditer(md))
|
| 1013 |
+
if not table_matches:
|
| 1014 |
+
return md
|
| 1015 |
+
|
| 1016 |
+
plans = []
|
| 1017 |
+
signed_known = []
|
| 1018 |
+
signed_unknown = []
|
| 1019 |
+
for ti, tm in enumerate(table_matches):
|
| 1020 |
+
full = tm.group(0)
|
| 1021 |
rows = _extract_rows_plain_from_table(full)
|
| 1022 |
if len(rows) < 3:
|
| 1023 |
+
continue
|
|
|
|
| 1024 |
hdr = [c.strip() for c in rows[0]]
|
| 1025 |
hdr_l = [h.lower() for h in hdr]
|
| 1026 |
date_i = next((i for i, h in enumerate(hdr_l) if "date" in h), None)
|
| 1027 |
+
desc_i = next((i for i, h in enumerate(hdr_l) if "description" in h or "memo" in h or "details" in h), None)
|
| 1028 |
+
if date_i is None or desc_i is None:
|
| 1029 |
+
continue
|
| 1030 |
+
balance_i = next((i for i, h in enumerate(hdr_l) if "balance" in h), None)
|
| 1031 |
+
amount_i = next((i for i, h in enumerate(hdr_l) if "amount" in h and "balance" not in h), None)
|
| 1032 |
credit_i = next((i for i, h in enumerate(hdr_l) if "credit" in h), None)
|
| 1033 |
debit_i = next((i for i, h in enumerate(hdr_l) if "debit" in h), None)
|
| 1034 |
+
if amount_i is None and (credit_i is None or debit_i is None):
|
| 1035 |
+
continue
|
| 1036 |
|
| 1037 |
+
ctx_left = re.sub(r"<[^>]+>", " ", md[max(0, tm.start() - 320): tm.start()])
|
| 1038 |
+
ctx_tbl = re.sub(r"<[^>]+>", " ", full[:800])
|
| 1039 |
+
bias = _table_sign_bias(ctx_left + " " + ctx_tbl)
|
| 1040 |
|
| 1041 |
max_len = max(len(hdr), max((len(r) for r in rows), default=0))
|
| 1042 |
+
recs = []
|
| 1043 |
+
for ri, r in enumerate(rows[1:], start=1):
|
| 1044 |
+
row = (r + [""] * (max_len - len(r)))[:max_len]
|
| 1045 |
+
dt = (row[date_i] or "").strip()
|
|
|
|
| 1046 |
if not _is_date_like(dt):
|
| 1047 |
continue
|
| 1048 |
+
desc = (row[desc_i] or "").strip()
|
| 1049 |
+
bal = (
|
| 1050 |
+
_parse_amount_or_none((row[balance_i] or "").strip())
|
| 1051 |
+
if balance_i is not None and balance_i < len(row)
|
| 1052 |
+
else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1053 |
)
|
| 1054 |
+
if amount_i is not None and amount_i < len(row):
|
| 1055 |
+
amt = _parse_amount_or_none((row[amount_i] or "").strip())
|
| 1056 |
+
signed = _signed_amount_from_row(desc, amt, bias)
|
| 1057 |
+
else:
|
| 1058 |
+
cr = _parse_amount_or_none((row[credit_i] or "").strip()) if credit_i < len(row) else None
|
| 1059 |
+
db = _parse_amount_or_none((row[debit_i] or "").strip()) if debit_i < len(row) else None
|
| 1060 |
+
signed = None
|
| 1061 |
+
if cr is not None and db is None:
|
| 1062 |
+
signed = abs(cr)
|
| 1063 |
+
elif db is not None and cr is None:
|
| 1064 |
+
signed = -abs(db)
|
| 1065 |
+
recs.append({"ri": ri, "row": row, "signed": signed, "balance": bal})
|
| 1066 |
+
if signed is None:
|
| 1067 |
+
signed_unknown.append((ti, ri))
|
| 1068 |
+
else:
|
| 1069 |
+
signed_known.append(float(signed))
|
| 1070 |
+
if len(recs) >= 3:
|
| 1071 |
+
plans.append({"ti": ti, "hdr": hdr, "balance_i": balance_i, "records": recs})
|
| 1072 |
|
| 1073 |
+
if not plans:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1074 |
return md
|
| 1075 |
|
| 1076 |
+
# If signs are mostly inverted for this statement, flip unknown-bias outcomes globally.
|
| 1077 |
+
flip_unknown = False
|
| 1078 |
+
if end_bal is not None and signed_known:
|
| 1079 |
+
fwd = start_bal + sum(signed_known)
|
| 1080 |
+
rev = start_bal - sum(signed_known)
|
| 1081 |
+
flip_unknown = abs(rev - end_bal) + 1e-6 < abs(fwd - end_bal)
|
|
|
|
|
|
|
| 1082 |
|
| 1083 |
+
pieces = []
|
| 1084 |
+
last = 0
|
| 1085 |
+
for ti, tm in enumerate(table_matches):
|
| 1086 |
+
pieces.append(md[last:tm.start()])
|
| 1087 |
+
full = tm.group(0)
|
| 1088 |
+
plan = next((p for p in plans if p["ti"] == ti), None)
|
| 1089 |
+
if not plan:
|
| 1090 |
+
pieces.append(full)
|
| 1091 |
+
last = tm.end()
|
| 1092 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1093 |
|
| 1094 |
+
hdr = plan["hdr"][:]
|
| 1095 |
+
balance_i = plan["balance_i"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1096 |
if balance_i is None:
|
| 1097 |
+
hdr.append("Balance")
|
| 1098 |
+
balance_i = len(hdr) - 1
|
| 1099 |
+
|
| 1100 |
+
run = start_bal
|
| 1101 |
+
row_by_ri = {}
|
| 1102 |
+
for rec in plan["records"]:
|
| 1103 |
+
signed = rec["signed"]
|
| 1104 |
+
if signed is None:
|
| 1105 |
+
row_by_ri[rec["ri"]] = rec["row"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
continue
|
| 1107 |
+
if flip_unknown:
|
| 1108 |
+
signed = -signed
|
| 1109 |
+
run += signed
|
| 1110 |
+
row = rec["row"][:]
|
| 1111 |
+
if len(row) < len(hdr):
|
| 1112 |
+
row += [""] * (len(hdr) - len(row))
|
| 1113 |
+
row[balance_i] = _fmt_money(run)
|
| 1114 |
+
row_by_ri[rec["ri"]] = row
|
| 1115 |
+
|
| 1116 |
+
base_rows = _extract_rows_plain_from_table(full)
|
| 1117 |
+
if len(base_rows) < 2:
|
| 1118 |
+
pieces.append(full)
|
| 1119 |
+
last = tm.end()
|
| 1120 |
+
continue
|
| 1121 |
+
out_rows = ["<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in hdr) + "</tr>"]
|
| 1122 |
+
for ri, old in enumerate(base_rows[1:], start=1):
|
| 1123 |
+
row = row_by_ri.get(ri, old)
|
| 1124 |
+
row = (row + [""] * (len(hdr) - len(row)))[: len(hdr)]
|
| 1125 |
+
out_rows.append("<tr>" + "".join(f"<td>{html.escape((c or '').strip())}</td>" for c in row) + "</tr>")
|
| 1126 |
+
pieces.append("<table>\n" + "\n".join(out_rows) + "\n</table>")
|
| 1127 |
+
last = tm.end()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1128 |
|
| 1129 |
+
pieces.append(md[last:])
|
| 1130 |
+
return "".join(pieces)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
|
| 1132 |
|
| 1133 |
def _extract_daily_balance_by_date(md: str):
|
|
|
|
| 1175 |
return out
|
| 1176 |
|
| 1177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1178 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
| 1179 |
import pymupdf as fitz
|
| 1180 |
from PIL import Image
|
|
|
|
| 1201 |
canvas.paste(img, (pad_l, pad_t))
|
| 1202 |
img = canvas
|
| 1203 |
|
| 1204 |
+
# Include a per-run unique tag to avoid filename collisions across parallel local runs.
|
| 1205 |
+
uniq = uuid.uuid4().hex[:10]
|
| 1206 |
+
img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{uniq}_{i}.png")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1207 |
img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1208 |
page_images.append(img_path)
|
| 1209 |
page_heights.append(img.height)
|
| 1210 |
|
|
|
|
| 1313 |
|
| 1314 |
merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
|
| 1315 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1316 |
merged = stabilize_table_markup(merged, rounds=2)
|
| 1317 |
merged = repair_thead_cell_semantics(merged)
|
| 1318 |
merged = strip_degenerate_html_tables(merged)
|
|
|
|
| 1327 |
finally:
|
| 1328 |
for p in page_images:
|
| 1329 |
try:
|
| 1330 |
+
if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
os.unlink(p)
|
| 1332 |
except Exception:
|
| 1333 |
pass
|