rehan953 commited on
Commit
4e256e3
Β·
verified Β·
1 Parent(s): 9fb875b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +280 -133
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 Summary Table extraction (Fix NF-1)
2195
  # --------------------------
2196
 
2197
- def _extract_nfcu_summary_table(pdf_path: str, page_num: int) -> str:
2198
  """
2199
- Extract Navy Federal 'Summary of your deposit accounts' table from the PDF
2200
- text layer using spatial word positions.
2201
 
2202
- GLM-OCR fails on this table because the five data columns are spread very
2203
- wide across the page and the account-name column has no visible border.
2204
- The PDF text layer contains the data perfectly β€” we read it with pdfplumber.
 
 
 
 
 
2205
 
2206
  Detection guards (all must pass β€” safe no-op for all other banks):
2207
- 1. A word 'Summary' must appear in the top 55% of the page height.
2208
- 2. Words 'deposit' or 'accounts' must appear within 15pt of that word.
 
2209
 
2210
- Returns HTML table string, or "" if not found or on any error.
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
- words = page.extract_words()
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
- # Guard 2: nearby words must confirm this is a deposit summary
2233
- nearby = [w['text'].lower() for w in words
2234
- if abs(w['top'] - summary_word['top']) < 15]
2235
- if 'deposit' not in nearby and 'accounts' not in nearby:
2236
- return ""
2237
 
2238
- # Band: start ~15pt below "Summary of..." heading, span ~115pt
2239
- y_start = summary_word['top'] + 15
2240
- y_end = summary_word['top'] + 115
 
 
 
 
 
 
2241
 
2242
- band_words = [w for w in words if y_start <= w['top'] <= y_end]
2243
- if not band_words:
2244
- return ""
2245
 
2246
- # Group words into lines by y-bucket (5pt tolerance)
2247
- lines_by_y = _defaultdict(list)
2248
- for w in band_words:
2249
- bucket = round(w['top'] / 5) * 5
2250
- lines_by_y[bucket].append(w)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2251
 
2252
- rows_raw = [sorted(lines_by_y[y], key=lambda w: w['x0'])
2253
- for y in sorted(lines_by_y.keys())]
 
 
 
 
 
 
 
 
 
2254
 
2255
- if len(rows_raw) < 3:
2256
- return ""
 
 
 
 
 
2257
 
2258
- # Column centers from first two header rows
2259
- # (two-line header: "Previous Deposits/ Withdrawals/ Ending YTD" /
2260
- # "Balance Credits Debits Balance Dividends")
2261
- header_words_flat = [w for r in rows_raw[:2] for w in r]
2262
- header_xs = sorted((w['x0'] + w['x1']) / 2 for w in header_words_flat)
2263
-
2264
- col_centers = []
2265
- cluster = [header_xs[0]]
2266
- for x in header_xs[1:]:
2267
- if x - cluster[-1] > 30:
2268
- col_centers.append(sum(cluster) / len(cluster))
2269
- cluster = [x]
2270
- else:
2271
- cluster.append(x)
2272
- col_centers.append(sum(cluster) / len(cluster))
2273
-
2274
- def _assign_col(x_center):
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
- if not merged:
2324
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2325
 
2326
- n_cols = len(col_labels)
2327
- header_html = '<tr><th>Account</th>' + ''.join(
2328
- f'<th>{lbl}</th>' for lbl in col_labels) + '</tr>'
2329
-
2330
- rows_html = []
2331
- for name, vals in merged:
2332
- row = f'<tr><td>{name}</td>'
2333
- for ci in range(n_cols):
2334
- row += f'<td>{vals.get(ci, "")}</td>'
2335
- row += '</tr>'
2336
- rows_html.append(row)
2337
-
2338
- return (
2339
- 'Summary of your deposit accounts\n'
2340
- '<table>\n' + header_html + '\n' +
2341
- '\n'.join(rows_html) + '\n</table>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2342
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2343
 
2344
  except Exception as e:
2345
- log.warning("_extract_nfcu_summary_table failed (page %d): %s", page_num, e)
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: prepend Navy Federal "Summary of your deposit accounts" table.
2593
- # Uses pdfplumber spatial word positions to extract the wide-column summary
2594
- # table that GLM-OCR fails to parse correctly.
2595
- # Safe no-op for all non-NFCU PDFs (returns "" when guards don't match).
 
 
2596
  if is_pdf:
2597
  try:
2598
- nfcu_summary = _extract_nfcu_summary_table(path, page_num)
2599
- if nfcu_summary:
2600
- parts.insert(0, nfcu_summary)
 
 
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.