Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -2191,158 +2191,301 @@ def convert_plaintext_bank_sections(text: str) -> str:
|
|
| 2191 |
return "\n".join(out)
|
| 2192 |
|
| 2193 |
# --------------------------
|
| 2194 |
-
# Navy Federal
|
| 2195 |
# --------------------------
|
| 2196 |
|
| 2197 |
-
def
|
| 2198 |
"""
|
| 2199 |
-
|
| 2200 |
-
text layer using spatial word positions.
|
| 2201 |
|
| 2202 |
-
GLM-OCR fails on
|
| 2203 |
-
|
| 2204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2205 |
|
| 2206 |
Detection guards (all must pass β safe no-op for all other banks):
|
| 2207 |
-
1.
|
| 2208 |
-
|
|
|
|
| 2209 |
|
| 2210 |
-
Returns HTML
|
| 2211 |
"""
|
| 2212 |
try:
|
| 2213 |
import pdfplumber
|
| 2214 |
-
from collections import defaultdict as _defaultdict
|
| 2215 |
|
| 2216 |
with pdfplumber.open(pdf_path) as pdf:
|
| 2217 |
if page_num >= len(pdf.pages):
|
| 2218 |
return ""
|
| 2219 |
page = pdf.pages[page_num]
|
| 2220 |
-
|
| 2221 |
-
page_h = float(page.height)
|
| 2222 |
-
|
| 2223 |
-
# Guard 1: find 'Summary' word in top 55% of page
|
| 2224 |
-
summary_word = next(
|
| 2225 |
-
(w for w in words
|
| 2226 |
-
if w['text'].lower() == 'summary' and w['top'] < page_h * 0.55),
|
| 2227 |
-
None
|
| 2228 |
-
)
|
| 2229 |
-
if not summary_word:
|
| 2230 |
-
return ""
|
| 2231 |
|
| 2232 |
-
|
| 2233 |
-
|
| 2234 |
-
if abs(w['top'] - summary_word['top']) < 15]
|
| 2235 |
-
if 'deposit' not in nearby and 'accounts' not in nearby:
|
| 2236 |
-
return ""
|
| 2237 |
|
| 2238 |
-
|
| 2239 |
-
|
| 2240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2241 |
|
| 2242 |
-
|
| 2243 |
-
if not band_words:
|
| 2244 |
-
return ""
|
| 2245 |
|
| 2246 |
-
|
| 2247 |
-
|
| 2248 |
-
|
| 2249 |
-
|
| 2250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2251 |
|
| 2252 |
-
|
| 2253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2254 |
|
| 2255 |
-
|
| 2256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2257 |
|
| 2258 |
-
|
| 2259 |
-
|
| 2260 |
-
|
| 2261 |
-
|
| 2262 |
-
|
| 2263 |
-
|
| 2264 |
-
|
| 2265 |
-
|
| 2266 |
-
|
| 2267 |
-
|
| 2268 |
-
|
| 2269 |
-
|
| 2270 |
-
|
| 2271 |
-
|
| 2272 |
-
|
| 2273 |
-
|
| 2274 |
-
|
| 2275 |
-
return min(range(len(col_centers)),
|
| 2276 |
-
key=lambda i: abs(col_centers[i] - x_center))
|
| 2277 |
-
|
| 2278 |
-
# Build column labels by merging two header rows
|
| 2279 |
-
col_labels = [''] * len(col_centers)
|
| 2280 |
-
for r in rows_raw[:2]:
|
| 2281 |
-
for w in r:
|
| 2282 |
-
xc = (w['x0'] + w['x1']) / 2
|
| 2283 |
-
ci = _assign_col(xc)
|
| 2284 |
-
sep = ' ' if col_labels[ci] else ''
|
| 2285 |
-
col_labels[ci] += sep + w['text']
|
| 2286 |
-
|
| 2287 |
-
# Data rows: each account has a name line + a values line
|
| 2288 |
-
_MONEY_PAT = re.compile(r'^\$[\d,]+\.?\d*-?$|^[\d,]+\.?\d*-?$')
|
| 2289 |
-
|
| 2290 |
-
merged = []
|
| 2291 |
-
current_name = []
|
| 2292 |
-
current_vals = {}
|
| 2293 |
-
|
| 2294 |
-
for line in rows_raw[2:]:
|
| 2295 |
-
texts = [w['text'] for w in line]
|
| 2296 |
-
has_money = any(_MONEY_PAT.match(t) for t in texts)
|
| 2297 |
-
is_totals = any(w['text'].lower() == 'totals' for w in line)
|
| 2298 |
-
|
| 2299 |
-
if has_money:
|
| 2300 |
-
# Values line β may also contain account number and/or 'Totals'
|
| 2301 |
-
val_words = [w for w in line
|
| 2302 |
-
if not re.match(r'^\d{7,}$', w['text'])
|
| 2303 |
-
and w['text'].lower() != 'totals']
|
| 2304 |
-
acct_words = [w for w in line if re.match(r'^\d{7,}$', w['text'])]
|
| 2305 |
-
|
| 2306 |
-
for w in val_words:
|
| 2307 |
-
xc = (w['x0'] + w['x1']) / 2
|
| 2308 |
-
ci = _assign_col(xc)
|
| 2309 |
-
current_vals[ci] = w['text']
|
| 2310 |
-
|
| 2311 |
-
name_str = ' '.join(current_name)
|
| 2312 |
-
if acct_words:
|
| 2313 |
-
name_str = (name_str + ' ' + acct_words[0]['text']).strip()
|
| 2314 |
-
if is_totals:
|
| 2315 |
-
name_str = 'Totals'
|
| 2316 |
-
|
| 2317 |
-
merged.append((name_str, dict(current_vals)))
|
| 2318 |
-
current_name = []
|
| 2319 |
-
current_vals = {}
|
| 2320 |
-
else:
|
| 2321 |
-
current_name.extend(texts)
|
| 2322 |
|
| 2323 |
-
|
| 2324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2325 |
|
| 2326 |
-
|
| 2327 |
-
|
| 2328 |
-
|
| 2329 |
-
|
| 2330 |
-
|
| 2331 |
-
|
| 2332 |
-
|
| 2333 |
-
|
| 2334 |
-
|
| 2335 |
-
|
| 2336 |
-
|
| 2337 |
-
|
| 2338 |
-
|
| 2339 |
-
|
| 2340 |
-
|
| 2341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2342 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2343 |
|
| 2344 |
except Exception as e:
|
| 2345 |
-
log.warning("
|
| 2346 |
return ""
|
| 2347 |
|
| 2348 |
def normalize_html_tables(text: str) -> str:
|
|
@@ -2589,17 +2732,21 @@ def run_ocr(uploaded_file):
|
|
| 2589 |
|
| 2590 |
parts.append(stabilized)
|
| 2591 |
|
| 2592 |
-
# Fix NF-1:
|
| 2593 |
-
#
|
| 2594 |
-
#
|
| 2595 |
-
#
|
|
|
|
|
|
|
| 2596 |
if is_pdf:
|
| 2597 |
try:
|
| 2598 |
-
|
| 2599 |
-
if
|
| 2600 |
-
parts
|
|
|
|
|
|
|
| 2601 |
except Exception:
|
| 2602 |
-
pass
|
| 2603 |
|
| 2604 |
# Footer extraction: PDF text layer -> band words -> OCR crop
|
| 2605 |
# Deduplication guard prevents double-printing when body OCR already captured it.
|
|
|
|
| 2191 |
return "\n".join(out)
|
| 2192 |
|
| 2193 |
# --------------------------
|
| 2194 |
+
# Navy Federal full-page text-layer parser (Fix NF-1)
|
| 2195 |
# --------------------------
|
| 2196 |
|
| 2197 |
+
def _parse_nfcu_page(pdf_path: str, page_num: int) -> str:
|
| 2198 |
"""
|
| 2199 |
+
Full text-layer parser for Navy Federal Credit Union statements.
|
|
|
|
| 2200 |
|
| 2201 |
+
GLM-OCR fails on NFCU pages because:
|
| 2202 |
+
- The summary table has 5 wide columns with no visible borders
|
| 2203 |
+
- Transaction tables have Amount($) and Balance($) spatially far right
|
| 2204 |
+
- Multi-line descriptions (wrapped lines) confuse OCR table detection
|
| 2205 |
+
|
| 2206 |
+
This function reads the PDF text layer directly via pdfplumber and produces
|
| 2207 |
+
correct, complete HTML for every section: summary table, transaction tables,
|
| 2208 |
+
items paid, savings, disclosures.
|
| 2209 |
|
| 2210 |
Detection guards (all must pass β safe no-op for all other banks):
|
| 2211 |
+
1. Page text must contain 'Access No.' AND 'Statement of Account'
|
| 2212 |
+
(unique to Navy Federal statement format)
|
| 2213 |
+
2. pdfplumber must return extractable text (not a scanned page)
|
| 2214 |
|
| 2215 |
+
Returns complete HTML string for the page, or "" if not NFCU / any error.
|
| 2216 |
"""
|
| 2217 |
try:
|
| 2218 |
import pdfplumber
|
|
|
|
| 2219 |
|
| 2220 |
with pdfplumber.open(pdf_path) as pdf:
|
| 2221 |
if page_num >= len(pdf.pages):
|
| 2222 |
return ""
|
| 2223 |
page = pdf.pages[page_num]
|
| 2224 |
+
text = page.extract_text() or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2225 |
|
| 2226 |
+
if not text:
|
| 2227 |
+
return ""
|
|
|
|
|
|
|
|
|
|
| 2228 |
|
| 2229 |
+
# Guard: must match NFCU format on this page OR on page 0
|
| 2230 |
+
# (pages 2+ still have "Access No." and "Statement of Account")
|
| 2231 |
+
is_nfcu = (
|
| 2232 |
+
('Access No.' in text and 'Statement of Account' in text) or
|
| 2233 |
+
('navyfederal' in text.lower()) or
|
| 2234 |
+
('Navy Federal' in text)
|
| 2235 |
+
)
|
| 2236 |
+
if not is_nfcu:
|
| 2237 |
+
return ""
|
| 2238 |
|
| 2239 |
+
lines = [l.strip() for l in text.splitlines()]
|
|
|
|
|
|
|
| 2240 |
|
| 2241 |
+
# ββ Regex patterns βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2242 |
+
# Full transaction: "02-03 Description 7.68- 3,974.66"
|
| 2243 |
+
TXN_FULL = re.compile(
|
| 2244 |
+
r'^(\d{2}-\d{2})\s+(.+?)\s+([\d,]+\.\d{2}-?)\s+([\d,]+\.\d{2}-?)$'
|
| 2245 |
+
)
|
| 2246 |
+
# Transaction with continuation: next line has "Location amt bal"
|
| 2247 |
+
TXN_NOAMT = re.compile(r'^(\d{2}-\d{2})\s+(.+)$')
|
| 2248 |
+
# Continuation line ending with amount + balance
|
| 2249 |
+
CONT_MONEY = re.compile(r'^(.+?)\s+([\d,]+\.\d{2}-?)\s+([\d,]+\.\d{2}-?)$')
|
| 2250 |
+
# Items Paid two-column row
|
| 2251 |
+
ITEMS_2COL = re.compile(
|
| 2252 |
+
r'^(\d{2}-\d{2})\s+(ACH|POS|CHECK|ATM|WIRE)\s+([\d,]+\.\d{2})'
|
| 2253 |
+
r'\s+(\d{2}-\d{2})\s+(ACH|POS|CHECK|ATM|WIRE)\s+([\d,]+\.\d{2})$'
|
| 2254 |
+
)
|
| 2255 |
+
ITEMS_1COL = re.compile(
|
| 2256 |
+
r'^(\d{2}-\d{2})\s+(ACH|POS|CHECK|ATM|WIRE)\s+([\d,]+\.\d{2})$'
|
| 2257 |
+
)
|
| 2258 |
+
BOILERPLATE = re.compile(
|
| 2259 |
+
r'^(Page \d+ of \d+|Access No\.\s*\d+|Statement Period'
|
| 2260 |
+
r'|\d{2}/\d{2}/\d{2}\s*-\s*\d{2}/\d{2}/\d{2}'
|
| 2261 |
+
r'|Statement of Account|For VIRTUAL EDUCATION STATION'
|
| 2262 |
+
r'|PO Box \d+.*|navyfederal\.org.*'
|
| 2263 |
+
r'|Say\s*"?Yes"?\s*to Paperless'
|
| 2264 |
+
r'|Insured\s*by\s*NCUA.*|InsuredbyNCUA.*'
|
| 2265 |
+
r'|IfyouhavenT?.*|Togetstarted.*|It\'saneasy.*'
|
| 2266 |
+
r'|digital statements via Mobile'
|
| 2267 |
+
r'|Navy Federal Online Banking\.)$'
|
| 2268 |
+
)
|
| 2269 |
+
SKIP_LINE = re.compile(
|
| 2270 |
+
r'^(REMITTANCE RECEIVED|VIRTUAL EDUCATION STATION|18511991'
|
| 2271 |
+
r'|DEPOSIT\s*VOUCHER|FOR MAIL USE ONLY|DEPOSITS MAY NOT'
|
| 2272 |
+
r'|ACCOUNT\s*NUMBER|MARK\s*"?X"?\s*TO CHANGE|ADDRESS/ORDER'
|
| 2273 |
+
r'|ITEMS ON REVERSE|NFCU|PO\s*BOX\s*3100|MERRIFIELD'
|
| 2274 |
+
r'|405715|CHANGE OF ADDRESS|PLEASE PRINT'
|
| 2275 |
+
r'|RANK/RATE|ADDRESS\(NO|CITY|STATE|ZIP|SIGNATURE'
|
| 2276 |
+
r'|EFFECTIVE DATE|HOME TELEPHONE|DAYTIME TELEPHONE'
|
| 2277 |
+
r'|- -|\( \)|Say Yes to Paperless).*$',
|
| 2278 |
+
re.IGNORECASE
|
| 2279 |
+
)
|
| 2280 |
|
| 2281 |
+
# ββ Helper: parse transaction block ββββββββββββββββββββββββββββββββ
|
| 2282 |
+
def parse_txn_block(i, stop_words):
|
| 2283 |
+
rows = []
|
| 2284 |
+
while i < len(lines):
|
| 2285 |
+
l = lines[i]
|
| 2286 |
+
if not l:
|
| 2287 |
+
i += 1; continue
|
| 2288 |
+
if BOILERPLATE.match(l) or SKIP_LINE.match(l):
|
| 2289 |
+
i += 1; continue
|
| 2290 |
+
if any(l == sw or l.startswith(sw) for sw in stop_words):
|
| 2291 |
+
break
|
| 2292 |
|
| 2293 |
+
# Full single-line transaction
|
| 2294 |
+
m = TXN_FULL.match(l)
|
| 2295 |
+
if m:
|
| 2296 |
+
date, desc, amt, bal = m.group(1), m.group(2), m.group(3), m.group(4)
|
| 2297 |
+
rows.append(f'<tr><td>{date}</td><td>{desc}</td><td>{amt}</td><td>{bal}</td></tr>')
|
| 2298 |
+
i += 1
|
| 2299 |
+
continue
|
| 2300 |
|
| 2301 |
+
# Multi-line: first line has date + partial desc, next has rest+amt+bal
|
| 2302 |
+
mn = TXN_NOAMT.match(l)
|
| 2303 |
+
if mn:
|
| 2304 |
+
date, desc1 = mn.group(1), mn.group(2)
|
| 2305 |
+
i += 1
|
| 2306 |
+
if i < len(lines):
|
| 2307 |
+
nxt = lines[i]
|
| 2308 |
+
mc = CONT_MONEY.match(nxt)
|
| 2309 |
+
if mc:
|
| 2310 |
+
desc2, amt, bal = mc.group(1), mc.group(2), mc.group(3)
|
| 2311 |
+
full_desc = (desc1 + ' ' + desc2).strip()
|
| 2312 |
+
rows.append(f'<tr><td>{date}</td><td>{full_desc}</td><td>{amt}</td><td>{bal}</td></tr>')
|
| 2313 |
+
i += 1
|
| 2314 |
+
continue
|
| 2315 |
+
# Next line is not a continuation β treat as balance-only
|
| 2316 |
+
rows.append(f'<tr><td>{date}</td><td>{desc1}</td><td></td><td></td></tr>')
|
| 2317 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2318 |
|
| 2319 |
+
i += 1
|
| 2320 |
+
return rows, i
|
| 2321 |
+
|
| 2322 |
+
# ββ Main parse loop ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2323 |
+
out = []
|
| 2324 |
+
i = 0
|
| 2325 |
+
TXN_STOP = [
|
| 2326 |
+
'Average Daily Balance', 'Items Paid', 'Savings', 'Checking',
|
| 2327 |
+
'Mbr Business Savings -', '2024 Year to Date', 'Your share balance',
|
| 2328 |
+
'Disclosure', 'What to Do', 'Errors Related', 'Errors Within', 'Payments'
|
| 2329 |
+
]
|
| 2330 |
+
|
| 2331 |
+
while i < len(lines):
|
| 2332 |
+
l = lines[i]
|
| 2333 |
+
|
| 2334 |
+
if not l or BOILERPLATE.match(l) or SKIP_LINE.match(l):
|
| 2335 |
+
i += 1; continue
|
| 2336 |
+
|
| 2337 |
+
# ββ Summary of your deposit accounts ββββββββββββββββββββββββββ
|
| 2338 |
+
if l == 'Summary of your deposit accounts':
|
| 2339 |
+
i += 1
|
| 2340 |
+
# Skip the two header lines ("Previous Deposits/..." and "Balance Credits...")
|
| 2341 |
+
while i < len(lines) and not re.match(r'^(Business|Mbr|Totals)', lines[i]):
|
| 2342 |
+
i += 1
|
| 2343 |
+
|
| 2344 |
+
out.append('<b>Summary of your deposit accounts</b>')
|
| 2345 |
+
out.append('<table>')
|
| 2346 |
+
out.append('<tr><th>Account</th><th>Previous Balance</th>'
|
| 2347 |
+
'<th>Deposits/Credits</th><th>Withdrawals/Debits</th>'
|
| 2348 |
+
'<th>Ending Balance</th><th>YTD Dividends</th></tr>')
|
| 2349 |
|
| 2350 |
+
while i < len(lines):
|
| 2351 |
+
l2 = lines[i]
|
| 2352 |
+
if not l2 or re.match(r'^(Checking$|#|REMITTANCE)', l2):
|
| 2353 |
+
break
|
| 2354 |
+
|
| 2355 |
+
if l2.startswith('Totals '):
|
| 2356 |
+
parts = l2.split()
|
| 2357 |
+
vals = parts[1:]
|
| 2358 |
+
while len(vals) < 5: vals.append('')
|
| 2359 |
+
cells = ''.join(f'<td>{v}</td>' for v in vals[:5])
|
| 2360 |
+
out.append(f'<tr><td><b>Totals</b></td>{cells}</tr>')
|
| 2361 |
+
i += 1
|
| 2362 |
+
break
|
| 2363 |
+
|
| 2364 |
+
if re.match(r'^(Business Checking|Mbr Business Savings)', l2):
|
| 2365 |
+
acct_name = l2
|
| 2366 |
+
i += 1
|
| 2367 |
+
if i < len(lines):
|
| 2368 |
+
val_line = lines[i]
|
| 2369 |
+
parts = val_line.split()
|
| 2370 |
+
if parts and re.match(r'^\d{7,}$', parts[0]):
|
| 2371 |
+
acct_num = parts[0]
|
| 2372 |
+
vals = parts[1:]
|
| 2373 |
+
else:
|
| 2374 |
+
acct_num = ''
|
| 2375 |
+
vals = parts
|
| 2376 |
+
while len(vals) < 5: vals.append('')
|
| 2377 |
+
cells = ''.join(f'<td>{v}</td>' for v in vals[:5])
|
| 2378 |
+
name_cell = f'{acct_name} ({acct_num})' if acct_num else acct_name
|
| 2379 |
+
out.append(f'<tr><td>{name_cell}</td>{cells}</tr>')
|
| 2380 |
+
i += 1
|
| 2381 |
+
continue
|
| 2382 |
+
i += 1
|
| 2383 |
+
|
| 2384 |
+
out.append('</table>')
|
| 2385 |
+
continue
|
| 2386 |
+
|
| 2387 |
+
# ββ Section headers ββββββββββββββββββββββββββββββββββββββββββββ
|
| 2388 |
+
if l in ('Checking', 'Savings'):
|
| 2389 |
+
out.append(f'<h3>{l}</h3>')
|
| 2390 |
+
i += 1
|
| 2391 |
+
continue
|
| 2392 |
+
|
| 2393 |
+
# ββ Account transaction table ββββββββββββββββββββββββοΏ½οΏ½βββββββββ
|
| 2394 |
+
m_acct = re.match(
|
| 2395 |
+
r'^(Business Checking|Mbr Business Savings) - (\d+)(.*)?$', l
|
| 2396 |
)
|
| 2397 |
+
if m_acct:
|
| 2398 |
+
acct_line = l
|
| 2399 |
+
i += 1
|
| 2400 |
+
if i < len(lines) and 'Continued' in lines[i]:
|
| 2401 |
+
acct_line += ' ' + lines[i]
|
| 2402 |
+
i += 1
|
| 2403 |
+
if i < len(lines) and lines[i].startswith('Date '):
|
| 2404 |
+
i += 1 # skip "Date Transaction Detail Amount($) Balance($)"
|
| 2405 |
+
|
| 2406 |
+
out.append(f'<b>{acct_line}</b>')
|
| 2407 |
+
out.append('<table>')
|
| 2408 |
+
out.append('<tr><th>Date</th><th>Transaction Detail</th>'
|
| 2409 |
+
'<th>Amount($)</th><th>Balance($)</th></tr>')
|
| 2410 |
+
|
| 2411 |
+
rows, i = parse_txn_block(i, TXN_STOP)
|
| 2412 |
+
out.extend(rows)
|
| 2413 |
+
out.append('</table>')
|
| 2414 |
+
continue
|
| 2415 |
+
|
| 2416 |
+
# ββ Items Paid βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2417 |
+
if l == 'Items Paid' or l.startswith('Items Paid (Continued'):
|
| 2418 |
+
out.append(f'<b>{l}</b>')
|
| 2419 |
+
out.append('<table>')
|
| 2420 |
+
out.append('<tr><th>Date</th><th>Item</th><th>Amount($)</th>'
|
| 2421 |
+
'<th>Date</th><th>Item</th><th>Amount($)</th></tr>')
|
| 2422 |
+
i += 1
|
| 2423 |
+
if i < len(lines) and lines[i].startswith('Date '):
|
| 2424 |
+
i += 1
|
| 2425 |
+
|
| 2426 |
+
ITEMS_STOP = ['Savings', '2024 Year to Date', 'Checking',
|
| 2427 |
+
'Disclosure', 'What to Do']
|
| 2428 |
+
while i < len(lines):
|
| 2429 |
+
l2 = lines[i]
|
| 2430 |
+
if not l2: i += 1; continue
|
| 2431 |
+
if any(l2 == sw or l2.startswith(sw) for sw in ITEMS_STOP):
|
| 2432 |
+
break
|
| 2433 |
+
if BOILERPLATE.match(l2) or SKIP_LINE.match(l2):
|
| 2434 |
+
i += 1; continue
|
| 2435 |
+
|
| 2436 |
+
m2c = ITEMS_2COL.match(l2)
|
| 2437 |
+
if m2c:
|
| 2438 |
+
d1,t1,a1,d2,t2,a2 = m2c.groups()
|
| 2439 |
+
out.append(f'<tr><td>{d1}</td><td>{t1}</td><td>{a1}</td>'
|
| 2440 |
+
f'<td>{d2}</td><td>{t2}</td><td>{a2}</td></tr>')
|
| 2441 |
+
i += 1; continue
|
| 2442 |
+
|
| 2443 |
+
m1c = ITEMS_1COL.match(l2)
|
| 2444 |
+
if m1c:
|
| 2445 |
+
d1,t1,a1 = m1c.groups()
|
| 2446 |
+
out.append(f'<tr><td>{d1}</td><td>{t1}</td><td>{a1}</td>'
|
| 2447 |
+
f'<td></td><td></td><td></td></tr>')
|
| 2448 |
+
i += 1; continue
|
| 2449 |
+
i += 1
|
| 2450 |
+
|
| 2451 |
+
out.append('</table>')
|
| 2452 |
+
continue
|
| 2453 |
+
|
| 2454 |
+
# ββ Average Daily Balance ββββββββββββββββββββββββββββββββββββββ
|
| 2455 |
+
if l.startswith('Average Daily Balance'):
|
| 2456 |
+
out.append(f'<p>{l}</p>')
|
| 2457 |
+
i += 1; continue
|
| 2458 |
+
|
| 2459 |
+
# ββ 2024 Year to Date ββββββββββββββββββββββββββββββββββββββββββ
|
| 2460 |
+
if l.startswith('2024 Year to Date'):
|
| 2461 |
+
out.append(f'<p><b>{l}</b></p>')
|
| 2462 |
+
i += 1
|
| 2463 |
+
while i < len(lines) and lines[i] and not BOILERPLATE.match(lines[i]):
|
| 2464 |
+
out.append(f'<p>{lines[i]}</p>')
|
| 2465 |
+
i += 1
|
| 2466 |
+
continue
|
| 2467 |
+
|
| 2468 |
+
# ββ Your share balance is overdrawn βββββββββββββββββββββββββββ
|
| 2469 |
+
if l.startswith('Your share balance'):
|
| 2470 |
+
out.append(f'<p><i>{l}</i></p>')
|
| 2471 |
+
i += 1; continue
|
| 2472 |
+
|
| 2473 |
+
# ββ Disclosure page β output as plain text ββββββββββββββββββββ
|
| 2474 |
+
if any(l.startswith(x) for x in
|
| 2475 |
+
('Disclosure', 'WhattoDoif', 'What to Do', 'Errors', 'Payments',
|
| 2476 |
+
'ErrorsRelated', 'ErrorsWithin')):
|
| 2477 |
+
out.append(f'<p>{l}</p>')
|
| 2478 |
+
i += 1; continue
|
| 2479 |
+
|
| 2480 |
+
# Default: keep non-empty lines
|
| 2481 |
+
if l:
|
| 2482 |
+
out.append(f'<p>{l}</p>')
|
| 2483 |
+
i += 1
|
| 2484 |
+
|
| 2485 |
+
return '\n'.join(out)
|
| 2486 |
|
| 2487 |
except Exception as e:
|
| 2488 |
+
log.warning("_parse_nfcu_page failed (page %d): %s", page_num, e)
|
| 2489 |
return ""
|
| 2490 |
|
| 2491 |
def normalize_html_tables(text: str) -> str:
|
|
|
|
| 2732 |
|
| 2733 |
parts.append(stabilized)
|
| 2734 |
|
| 2735 |
+
# Fix NF-1: full text-layer parse for Navy Federal statements.
|
| 2736 |
+
# GLM-OCR fails on NFCU pages (summary table, transaction columns,
|
| 2737 |
+
# multi-line descriptions). When NFCU is detected, replace the OCR
|
| 2738 |
+
# body with a clean text-layer extraction and clear the parts list
|
| 2739 |
+
# to avoid mixing garbled OCR output with correct text-layer data.
|
| 2740 |
+
# Safe no-op for all non-NFCU PDFs (_parse_nfcu_page returns "").
|
| 2741 |
if is_pdf:
|
| 2742 |
try:
|
| 2743 |
+
nfcu_html = _parse_nfcu_page(path, page_num)
|
| 2744 |
+
if nfcu_html:
|
| 2745 |
+
# Replace ALL parts with the text-layer result β
|
| 2746 |
+
# discard garbled OCR header/body/footer for this page
|
| 2747 |
+
parts = [nfcu_html]
|
| 2748 |
except Exception:
|
| 2749 |
+
pass # safe no-op β falls back to OCR output on any error
|
| 2750 |
|
| 2751 |
# Footer extraction: PDF text layer -> band words -> OCR crop
|
| 2752 |
# Deduplication guard prevents double-printing when body OCR already captured it.
|