Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -119,6 +119,12 @@ Primary goals for reconcile rate:
|
|
| 119 |
five columns using trailing amount in the description cell and TD-specific debit/credit
|
| 120 |
routing. Fires on \"continued\" in-table text and/or TD body cues (TD ZELLE, LENDINGCLUB,
|
| 121 |
etc.). Safe no-op elsewhere.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
- Fix MD-1: Markdown fidelity — (a) collapse adjacent HTML tables that are the same register
|
| 123 |
(identical multiset of date + debit + credit + balance per row, descriptions may differ);
|
| 124 |
keep the table with richer description text so output matches the PDF once. (b) Common OCR
|
|
@@ -2972,6 +2978,187 @@ def _fix_three_col_date_desc_amount_left_shift(grid):
|
|
| 2972 |
return out if changed else grid
|
| 2973 |
|
| 2974 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2975 |
def _normalize_fused_date_posted_amount_table(grid):
|
| 2976 |
if not grid or len(grid) < 2:
|
| 2977 |
return grid
|
|
@@ -3441,6 +3628,8 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3441 |
grid = _normalize_double_desc_amount_header_table(grid)
|
| 3442 |
grid = _normalize_ucb_three_col_beginning_balance_fusion(grid)
|
| 3443 |
grid = _fix_three_col_date_desc_amount_left_shift(grid)
|
|
|
|
|
|
|
| 3444 |
|
| 3445 |
grid = _drop_truly_empty_columns(grid)
|
| 3446 |
grid = _merge_blank_header_text_columns(grid)
|
|
@@ -3482,6 +3671,7 @@ def normalize_html_tables(text: str) -> str:
|
|
| 3482 |
|
| 3483 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 3484 |
grid = _dedupe_duplicate_rows_in_da3_table(grid)
|
|
|
|
| 3485 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
| 3486 |
if split_tables:
|
| 3487 |
out.append("\n\n".join(_grid_to_html(g) for g in split_tables if g))
|
|
@@ -3657,6 +3847,22 @@ def _is_da3_header(grid):
|
|
| 3657 |
)
|
| 3658 |
|
| 3659 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3660 |
_DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$")
|
| 3661 |
_DA3_MONEY_TOKEN = re.compile(
|
| 3662 |
r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
|
|
@@ -3774,6 +3980,67 @@ def _dedupe_duplicate_rows_in_da3_table(grid):
|
|
| 3774 |
return out
|
| 3775 |
|
| 3776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3777 |
def _row_text_full_ucb(grid, r: int) -> str:
|
| 3778 |
if not grid or r < 0 or r >= len(grid):
|
| 3779 |
return ""
|
|
|
|
| 119 |
five columns using trailing amount in the description cell and TD-specific debit/credit
|
| 120 |
routing. Fires on \"continued\" in-table text and/or TD body cues (TD ZELLE, LENDINGCLUB,
|
| 121 |
etc.). Safe no-op elsewhere.
|
| 122 |
+
- Fix SE-CONT: CO-OP / credit-union account banner (BUSINESS - N [CONT]) often emits three columns
|
| 123 |
+
| Amount | Amount | (date+desc | amount | balance) or a four-column variant (date+desc
|
| 124 |
+
fragment | desc tail | amount | balance). CONT may be omitted by OCR. Downstream mis-maps
|
| 125 |
+
amount vs balance. Normalize to Date | Transaction Description | Transaction Amount | Balance.
|
| 126 |
+
- Fix DA4-dedupe: In 4-column Date|…|Amount|Balance registers, consecutive rows identical on all four
|
| 127 |
+
cells (same running balance) are OCR stutters — collapse to one; honour text-layer counts when present.
|
| 128 |
- Fix MD-1: Markdown fidelity — (a) collapse adjacent HTML tables that are the same register
|
| 129 |
(identical multiset of date + debit + credit + balance per row, descriptions may differ);
|
| 130 |
keep the table with richer description text so output matches the PDF once. (b) Common OCR
|
|
|
|
| 2978 |
return out if changed else grid
|
| 2979 |
|
| 2980 |
|
| 2981 |
+
# CO-OP account banner: digits + optional CONT (OCR often truncates "CONT")
|
| 2982 |
+
_BUSINESS_COOP_BANNER_FIRST_CELL_RE = re.compile(
|
| 2983 |
+
r"^BUSINESS\s*-\s*\d+(?:\s*CONT)?\s*$",
|
| 2984 |
+
re.IGNORECASE,
|
| 2985 |
+
)
|
| 2986 |
+
|
| 2987 |
+
|
| 2988 |
+
def _first_cell_is_business_coop_banner(s: str) -> bool:
|
| 2989 |
+
t = str(s or "").strip()
|
| 2990 |
+
return bool(t and _BUSINESS_COOP_BANNER_FIRST_CELL_RE.match(t))
|
| 2991 |
+
|
| 2992 |
+
|
| 2993 |
+
def _fix_business_coop_cont_three_col_register_grid(grid):
|
| 2994 |
+
"""
|
| 2995 |
+
Fix SE-CONT: CO-OP network continuation (BUSINESS-…CONT | Amount | Amount) where col1 is
|
| 2996 |
+
MM/DD + description, col2 is transaction amount, col3 is running balance. Downstream
|
| 2997 |
+
extraction treats the two money columns like Amount/Balance in a different column order,
|
| 2998 |
+
inflating debits/credits. Expand to the same 4-column register as the first page.
|
| 2999 |
+
"""
|
| 3000 |
+
if not grid or len(grid) < 3:
|
| 3001 |
+
return grid
|
| 3002 |
+
|
| 3003 |
+
def _tight_cell(s: str) -> str:
|
| 3004 |
+
return re.sub(r"\s+", "", str(s or "").strip().lower())
|
| 3005 |
+
|
| 3006 |
+
hdr_idx = None
|
| 3007 |
+
scan = min(5, len(grid))
|
| 3008 |
+
for ri in range(scan):
|
| 3009 |
+
cells = [str(c or "").strip() for c in grid[ri]]
|
| 3010 |
+
while len(cells) < 3:
|
| 3011 |
+
cells.append("")
|
| 3012 |
+
if len(cells) < 3:
|
| 3013 |
+
continue
|
| 3014 |
+
if _first_cell_is_business_coop_banner(cells[0]) and _tight_cell(
|
| 3015 |
+
cells[1]
|
| 3016 |
+
) == "amount" and _tight_cell(cells[2]) == "amount":
|
| 3017 |
+
hdr_idx = ri
|
| 3018 |
+
break
|
| 3019 |
+
if hdr_idx is None:
|
| 3020 |
+
return grid
|
| 3021 |
+
|
| 3022 |
+
date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL)
|
| 3023 |
+
money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 3024 |
+
|
| 3025 |
+
def _is_date_txn_desc_subheader(cells: List[str]) -> bool:
|
| 3026 |
+
c0 = str(cells[0] or "").strip().lower()
|
| 3027 |
+
return (
|
| 3028 |
+
"date" in c0
|
| 3029 |
+
and "transaction" in c0
|
| 3030 |
+
and "description" in c0
|
| 3031 |
+
and len(c0) < 80
|
| 3032 |
+
)
|
| 3033 |
+
|
| 3034 |
+
data_start = hdr_idx + 1
|
| 3035 |
+
if data_start < len(grid):
|
| 3036 |
+
r1 = [str(c or "").strip() for c in grid[data_start]]
|
| 3037 |
+
while len(r1) < 3:
|
| 3038 |
+
r1.append("")
|
| 3039 |
+
if _is_date_txn_desc_subheader(r1):
|
| 3040 |
+
data_start += 1
|
| 3041 |
+
|
| 3042 |
+
new_grid = [
|
| 3043 |
+
["Date", "Transaction Description", "Transaction Amount", "Balance"],
|
| 3044 |
+
]
|
| 3045 |
+
changed = False
|
| 3046 |
+
for row in grid[data_start:]:
|
| 3047 |
+
cells = [str(c or "").strip() for c in row]
|
| 3048 |
+
while len(cells) < 3:
|
| 3049 |
+
cells.append("")
|
| 3050 |
+
c0, c1, c2 = cells[0], cells[1], cells[2]
|
| 3051 |
+
if not c0:
|
| 3052 |
+
continue
|
| 3053 |
+
m = date_desc_re.match(c0.strip())
|
| 3054 |
+
if not m:
|
| 3055 |
+
continue
|
| 3056 |
+
amt = (c1 or "").strip()
|
| 3057 |
+
bal = (c2 or "").strip()
|
| 3058 |
+
if not amt or not bal:
|
| 3059 |
+
continue
|
| 3060 |
+
if not money_re.match(amt) or not money_re.match(bal):
|
| 3061 |
+
continue
|
| 3062 |
+
new_grid.append(
|
| 3063 |
+
[
|
| 3064 |
+
m.group(1).strip(),
|
| 3065 |
+
(m.group(2) or "").strip(),
|
| 3066 |
+
amt,
|
| 3067 |
+
bal,
|
| 3068 |
+
]
|
| 3069 |
+
)
|
| 3070 |
+
changed = True
|
| 3071 |
+
|
| 3072 |
+
if not changed or len(new_grid) < 2:
|
| 3073 |
+
return grid
|
| 3074 |
+
return new_grid
|
| 3075 |
+
|
| 3076 |
+
|
| 3077 |
+
def _fix_business_coop_cont_four_col_register_grid(grid):
|
| 3078 |
+
"""
|
| 3079 |
+
Fix SE-CONT (4-col): Same CO-OP continuation banner as 3-col, but OCR uses four columns:
|
| 3080 |
+
BUSINESS-…CONT | (blank) | Amount | Amount, then subheader, then rows where col0 is
|
| 3081 |
+
MM/DD + description start, col1 is description tail (e.g. SEATTLE), col2 amount, col3 balance.
|
| 3082 |
+
Without merging, downstream maps columns incorrectly. Output canonical 4-column register.
|
| 3083 |
+
"""
|
| 3084 |
+
if not grid or len(grid) < 3:
|
| 3085 |
+
return grid
|
| 3086 |
+
|
| 3087 |
+
ncols = max(len(r) for r in grid if isinstance(r, list))
|
| 3088 |
+
if ncols < 4:
|
| 3089 |
+
return grid
|
| 3090 |
+
|
| 3091 |
+
def _tight_cell(s: str) -> str:
|
| 3092 |
+
return re.sub(r"\s+", "", str(s or "").strip().lower())
|
| 3093 |
+
|
| 3094 |
+
hdr_idx = None
|
| 3095 |
+
for ri in range(min(6, len(grid))):
|
| 3096 |
+
cells = [str(c or "").strip() for c in grid[ri]]
|
| 3097 |
+
while len(cells) < ncols:
|
| 3098 |
+
cells.append("")
|
| 3099 |
+
if len(cells) < 4:
|
| 3100 |
+
continue
|
| 3101 |
+
if not _first_cell_is_business_coop_banner(cells[0]):
|
| 3102 |
+
continue
|
| 3103 |
+
if _tight_cell(cells[-2]) != "amount" or _tight_cell(cells[-1]) != "amount":
|
| 3104 |
+
continue
|
| 3105 |
+
hdr_idx = ri
|
| 3106 |
+
break
|
| 3107 |
+
if hdr_idx is None:
|
| 3108 |
+
return grid
|
| 3109 |
+
|
| 3110 |
+
date_desc_re = re.compile(r"^(\d{1,2}/\d{1,2})\s+(.+)$", re.DOTALL)
|
| 3111 |
+
money_re = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})-?$")
|
| 3112 |
+
|
| 3113 |
+
def _skip_subheader(cells: List[str]) -> bool:
|
| 3114 |
+
c0 = str(cells[0] or "").strip()
|
| 3115 |
+
cl = c0.lower()
|
| 3116 |
+
if "date" not in cl or "transaction" not in cl or "description" not in cl:
|
| 3117 |
+
return False
|
| 3118 |
+
return not bool(date_desc_re.match(c0.strip()))
|
| 3119 |
+
|
| 3120 |
+
data_start = hdr_idx + 1
|
| 3121 |
+
while data_start < len(grid):
|
| 3122 |
+
cells = [str(c or "").strip() for c in grid[data_start]]
|
| 3123 |
+
while len(cells) < ncols:
|
| 3124 |
+
cells.append("")
|
| 3125 |
+
if _skip_subheader(cells):
|
| 3126 |
+
data_start += 1
|
| 3127 |
+
continue
|
| 3128 |
+
break
|
| 3129 |
+
|
| 3130 |
+
new_grid = [
|
| 3131 |
+
["Date", "Transaction Description", "Transaction Amount", "Balance"],
|
| 3132 |
+
]
|
| 3133 |
+
changed = False
|
| 3134 |
+
for row in grid[data_start:]:
|
| 3135 |
+
cells = [str(c or "").strip() for c in row]
|
| 3136 |
+
while len(cells) < ncols:
|
| 3137 |
+
cells.append("")
|
| 3138 |
+
c0 = cells[0] if cells else ""
|
| 3139 |
+
c1 = cells[1] if len(cells) > 1 else ""
|
| 3140 |
+
c2 = cells[2] if len(cells) > 2 else ""
|
| 3141 |
+
c3 = cells[3] if len(cells) > 3 else ""
|
| 3142 |
+
if not c0:
|
| 3143 |
+
continue
|
| 3144 |
+
m = date_desc_re.match(c0.strip())
|
| 3145 |
+
if not m:
|
| 3146 |
+
continue
|
| 3147 |
+
amt = (c2 or "").strip()
|
| 3148 |
+
bal = (c3 or "").strip()
|
| 3149 |
+
if not amt or not bal:
|
| 3150 |
+
continue
|
| 3151 |
+
if not money_re.match(amt) or not money_re.match(bal):
|
| 3152 |
+
continue
|
| 3153 |
+
desc = ((m.group(2) or "").strip() + " " + (c1 or "").strip()).strip()
|
| 3154 |
+
new_grid.append([m.group(1).strip(), desc, amt, bal])
|
| 3155 |
+
changed = True
|
| 3156 |
+
|
| 3157 |
+
if not changed or len(new_grid) < 2:
|
| 3158 |
+
return grid
|
| 3159 |
+
return new_grid
|
| 3160 |
+
|
| 3161 |
+
|
| 3162 |
def _normalize_fused_date_posted_amount_table(grid):
|
| 3163 |
if not grid or len(grid) < 2:
|
| 3164 |
return grid
|
|
|
|
| 3628 |
grid = _normalize_double_desc_amount_header_table(grid)
|
| 3629 |
grid = _normalize_ucb_three_col_beginning_balance_fusion(grid)
|
| 3630 |
grid = _fix_three_col_date_desc_amount_left_shift(grid)
|
| 3631 |
+
grid = _fix_business_coop_cont_three_col_register_grid(grid)
|
| 3632 |
+
grid = _fix_business_coop_cont_four_col_register_grid(grid)
|
| 3633 |
|
| 3634 |
grid = _drop_truly_empty_columns(grid)
|
| 3635 |
grid = _merge_blank_header_text_columns(grid)
|
|
|
|
| 3671 |
|
| 3672 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 3673 |
grid = _dedupe_duplicate_rows_in_da3_table(grid)
|
| 3674 |
+
grid = _dedupe_consecutive_identical_da4_register_rows(grid)
|
| 3675 |
split_tables = _split_combined_summary_daily_balance_grid(grid)
|
| 3676 |
if split_tables:
|
| 3677 |
out.append("\n\n".join(_grid_to_html(g) for g in split_tables if g))
|
|
|
|
| 3847 |
)
|
| 3848 |
|
| 3849 |
|
| 3850 |
+
def _is_da4_date_desc_amount_balance_header(grid):
|
| 3851 |
+
"""Date | …Description… | …Amount… | …Balance… (4-column running-balance register)."""
|
| 3852 |
+
if not grid or len(grid[0]) < 4:
|
| 3853 |
+
return False
|
| 3854 |
+
h = [re.sub(r"\s+", " ", str(c or "").strip().lower()) for c in grid[0][:4]]
|
| 3855 |
+
if h[0] != "date":
|
| 3856 |
+
return False
|
| 3857 |
+
if "description" not in h[1]:
|
| 3858 |
+
return False
|
| 3859 |
+
if "amount" not in h[2]:
|
| 3860 |
+
return False
|
| 3861 |
+
if "balance" not in h[3]:
|
| 3862 |
+
return False
|
| 3863 |
+
return True
|
| 3864 |
+
|
| 3865 |
+
|
| 3866 |
_DA3_STRICT_AMOUNT = re.compile(r"^-?\$?\d{1,3}(?:,\d{3})*\.\d{2}-?$")
|
| 3867 |
_DA3_MONEY_TOKEN = re.compile(
|
| 3868 |
r"(?<![\d,])(-?\$?\d{1,3}(?:,\d{3})*\.\d{2}|-?\$?\d{2,6}\.\d{2})(?!\d)"
|
|
|
|
| 3980 |
return out
|
| 3981 |
|
| 3982 |
|
| 3983 |
+
def _da4_register_row_key(cells: List[str]) -> Tuple[str, str, str, str]:
|
| 3984 |
+
parts = [str(c or "").strip() for c in cells[:4]]
|
| 3985 |
+
while len(parts) < 4:
|
| 3986 |
+
parts.append("")
|
| 3987 |
+
d0, d1, d2, d3 = parts[0], parts[1], parts[2], parts[3]
|
| 3988 |
+
date_s = re.sub(r"\s+", "", d0)
|
| 3989 |
+
amt_s = re.sub(r"[\s$,]", "", d2).lower().replace("−", "-")
|
| 3990 |
+
bal_s = re.sub(r"[\s$,]", "", d3).lower().replace("−", "-")
|
| 3991 |
+
desc_s = html.unescape(d1)
|
| 3992 |
+
desc_s = desc_s.replace("`", "'").replace("'", "'")
|
| 3993 |
+
desc_s = re.sub(r"\s+", "", desc_s.lower())
|
| 3994 |
+
return (date_s, desc_s, amt_s, bal_s)
|
| 3995 |
+
|
| 3996 |
+
|
| 3997 |
+
def _dedupe_consecutive_identical_da4_register_rows(grid):
|
| 3998 |
+
"""
|
| 3999 |
+
Fix SE-CONT / generic: OCR often repeats the same register line twice at page breaks
|
| 4000 |
+
(identical date, description, amount, and running balance). Real back-to-back
|
| 4001 |
+
duplicates would show different balances. Collapse consecutive full-row matches;
|
| 4002 |
+
honour text-layer counts when >1 (same 3-col key as Fix 20).
|
| 4003 |
+
"""
|
| 4004 |
+
if not grid or len(grid) < 2:
|
| 4005 |
+
return grid
|
| 4006 |
+
if not _is_da4_date_desc_amount_balance_header(grid):
|
| 4007 |
+
return grid
|
| 4008 |
+
|
| 4009 |
+
data_rows = []
|
| 4010 |
+
for row in grid[1:]:
|
| 4011 |
+
cells = [str(c or "").strip() for c in row]
|
| 4012 |
+
if not any(cells):
|
| 4013 |
+
continue
|
| 4014 |
+
data_rows.append(row)
|
| 4015 |
+
|
| 4016 |
+
if not data_rows:
|
| 4017 |
+
return grid
|
| 4018 |
+
|
| 4019 |
+
out = [grid[0]]
|
| 4020 |
+
i = 0
|
| 4021 |
+
while i < len(data_rows):
|
| 4022 |
+
row = data_rows[i]
|
| 4023 |
+
cells = [str(c or "").strip() for c in row]
|
| 4024 |
+
k4 = _da4_register_row_key(cells)
|
| 4025 |
+
j = i + 1
|
| 4026 |
+
while j < len(data_rows):
|
| 4027 |
+
ncells = [str(c or "").strip() for c in data_rows[j]]
|
| 4028 |
+
if _da4_register_row_key(ncells) != k4:
|
| 4029 |
+
break
|
| 4030 |
+
j += 1
|
| 4031 |
+
run_len = j - i
|
| 4032 |
+
k3 = _transaction_row_dedupe_key(cells)
|
| 4033 |
+
tl_confirmed = _tl_freq_context.get(k3, 0)
|
| 4034 |
+
# Default cap 1 for identical running-balance rows; allow more if PDF text layer says so.
|
| 4035 |
+
cap = max(tl_confirmed, 1) if tl_confirmed > 0 else 1
|
| 4036 |
+
take = min(run_len, cap)
|
| 4037 |
+
for t in range(take):
|
| 4038 |
+
out.append(data_rows[i + t])
|
| 4039 |
+
i = j
|
| 4040 |
+
|
| 4041 |
+
return out
|
| 4042 |
+
|
| 4043 |
+
|
| 4044 |
def _row_text_full_ucb(grid, r: int) -> str:
|
| 4045 |
if not grid or r < 0 or r >= len(grid):
|
| 4046 |
return ""
|