Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -164,7 +164,7 @@ if CONFIG_PATH:
|
|
| 164 |
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 165 |
config = yaml.safe_load(f)
|
| 166 |
config.setdefault("pipeline", {}).setdefault("maas", {})
|
| 167 |
-
config["pipeline"]["maas"]["enabled"] =
|
| 168 |
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
|
| 169 |
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
| 170 |
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
@@ -910,6 +910,288 @@ def light_stabilize_markdown(page_md: str) -> str:
|
|
| 910 |
return merged
|
| 911 |
|
| 912 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 913 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
| 914 |
import pymupdf as fitz
|
| 915 |
from PIL import Image
|
|
@@ -1047,6 +1329,9 @@ def run_ocr(uploaded_file):
|
|
| 1047 |
merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
|
| 1048 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
| 1049 |
merged = stabilize_table_markup(merged, rounds=4)
|
|
|
|
|
|
|
|
|
|
| 1050 |
merged = repair_thead_cell_semantics(merged)
|
| 1051 |
merged = strip_degenerate_html_tables(merged)
|
| 1052 |
return merged
|
|
|
|
| 164 |
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
| 165 |
config = yaml.safe_load(f)
|
| 166 |
config.setdefault("pipeline", {}).setdefault("maas", {})
|
| 167 |
+
config["pipeline"]["maas"]["enabled"] = TrueCONFIG_PATH
|
| 168 |
config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
|
| 169 |
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
| 170 |
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
|
|
| 910 |
return merged
|
| 911 |
|
| 912 |
|
| 913 |
+
def _parse_amount_or_none(s: str):
|
| 914 |
+
t = (s or "").strip()
|
| 915 |
+
if not t:
|
| 916 |
+
return None
|
| 917 |
+
t = t.replace("$", "").replace(",", "").replace("(", "-").replace(")", "")
|
| 918 |
+
t = t.replace("−", "-").replace("–", "-").replace("—", "-")
|
| 919 |
+
if not re.search(r"\d", t):
|
| 920 |
+
return None
|
| 921 |
+
try:
|
| 922 |
+
return float(t)
|
| 923 |
+
except Exception:
|
| 924 |
+
return None
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
def _extract_rows_plain_from_table(full_table: str) -> List[List[str]]:
|
| 928 |
+
rows: List[List[str]] = []
|
| 929 |
+
for tr in re.finditer(r"<tr\b[^>]*>.*?</tr>", full_table, flags=re.IGNORECASE | re.DOTALL):
|
| 930 |
+
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr.group(0), flags=re.IGNORECASE | re.DOTALL)
|
| 931 |
+
if not inner_m:
|
| 932 |
+
continue
|
| 933 |
+
inner = inner_m.group(1)
|
| 934 |
+
cells = [_cell_plain_text(m.group(0)) for m in _CELL.finditer(inner)]
|
| 935 |
+
if cells:
|
| 936 |
+
rows.append(cells)
|
| 937 |
+
return rows
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
def _extract_daily_balance_by_date(md: str):
|
| 941 |
+
"""
|
| 942 |
+
Build a date->balance map from daily-balance style tables.
|
| 943 |
+
Supports compact statements with repeated Date/Balance column pairs.
|
| 944 |
+
"""
|
| 945 |
+
out = {}
|
| 946 |
+
for tm in re.finditer(r"<table\b[^>]*>.*?</table>", md, flags=re.IGNORECASE | re.DOTALL):
|
| 947 |
+
t = tm.group(0)
|
| 948 |
+
rows = _extract_rows_plain_from_table(t)
|
| 949 |
+
if len(rows) < 2:
|
| 950 |
+
continue
|
| 951 |
+
hdr = [c.strip().lower() for c in rows[0]]
|
| 952 |
+
if not hdr:
|
| 953 |
+
continue
|
| 954 |
+
date_cols = [i for i, c in enumerate(hdr) if "date" in c]
|
| 955 |
+
bal_cols = [i for i, c in enumerate(hdr) if "balance" in c]
|
| 956 |
+
if not date_cols or not bal_cols:
|
| 957 |
+
continue
|
| 958 |
+
pairs = []
|
| 959 |
+
for di in date_cols:
|
| 960 |
+
bi = next((b for b in bal_cols if b > di), None)
|
| 961 |
+
if bi is not None:
|
| 962 |
+
pairs.append((di, bi))
|
| 963 |
+
if not pairs:
|
| 964 |
+
continue
|
| 965 |
+
for r in rows[1:]:
|
| 966 |
+
for di, bi in pairs:
|
| 967 |
+
if di >= len(r) or bi >= len(r):
|
| 968 |
+
continue
|
| 969 |
+
d = (r[di] or "").strip()
|
| 970 |
+
if not re.search(r"\b\d{1,2}/\d{1,2}(?:/\d{2,4})?\b", d):
|
| 971 |
+
continue
|
| 972 |
+
m = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d)
|
| 973 |
+
if not m:
|
| 974 |
+
continue
|
| 975 |
+
key = m.group(0)
|
| 976 |
+
bal = _parse_amount_or_none(r[bi])
|
| 977 |
+
if bal is None:
|
| 978 |
+
continue
|
| 979 |
+
out[key] = bal
|
| 980 |
+
short = "/".join(key.split("/")[:2])
|
| 981 |
+
out[short] = bal
|
| 982 |
+
return out
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
def enrich_transaction_tables_with_daily_balances(md: str) -> str:
|
| 986 |
+
"""
|
| 987 |
+
If transaction tables have Date+Amount but no Balance column, append a Balance
|
| 988 |
+
column using date-matched daily ending balances from the same document.
|
| 989 |
+
"""
|
| 990 |
+
if not md or "<table" not in md.lower():
|
| 991 |
+
return md
|
| 992 |
+
dmap = _extract_daily_balance_by_date(md)
|
| 993 |
+
if not dmap:
|
| 994 |
+
return md
|
| 995 |
+
|
| 996 |
+
def repl(m: re.Match) -> str:
|
| 997 |
+
table = m.group(0)
|
| 998 |
+
if re.search(r"(?:colspan|rowspan)\s*=", table, flags=re.IGNORECASE):
|
| 999 |
+
return table
|
| 1000 |
+
tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", table, flags=re.IGNORECASE | re.DOTALL))
|
| 1001 |
+
if len(tr_blocks) < 2:
|
| 1002 |
+
return table
|
| 1003 |
+
first = tr_blocks[0].group(0)
|
| 1004 |
+
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", first, flags=re.IGNORECASE | re.DOTALL)
|
| 1005 |
+
if not h_inner_m:
|
| 1006 |
+
return table
|
| 1007 |
+
h_inner = h_inner_m.group(1)
|
| 1008 |
+
h_cells = list(_CELL.finditer(h_inner))
|
| 1009 |
+
if not h_cells:
|
| 1010 |
+
return table
|
| 1011 |
+
hdr_txt = [_cell_plain_text(c.group(0)).strip().lower() for c in h_cells]
|
| 1012 |
+
if any("balance" in c for c in hdr_txt):
|
| 1013 |
+
return table
|
| 1014 |
+
if not any("date" in c for c in hdr_txt):
|
| 1015 |
+
return table
|
| 1016 |
+
if not any(("amount" in c) or ("debit" in c) or ("credit" in c) for c in hdr_txt):
|
| 1017 |
+
return table
|
| 1018 |
+
date_idx = next((i for i, c in enumerate(hdr_txt) if "date" in c), -1)
|
| 1019 |
+
if date_idx < 0:
|
| 1020 |
+
return table
|
| 1021 |
+
|
| 1022 |
+
new_parts: List[str] = []
|
| 1023 |
+
cursor = 0
|
| 1024 |
+
added = 0
|
| 1025 |
+
for i, trm in enumerate(tr_blocks):
|
| 1026 |
+
new_parts.append(table[cursor : trm.start()])
|
| 1027 |
+
tr_seg = trm.group(0)
|
| 1028 |
+
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
|
| 1029 |
+
if not inner_m:
|
| 1030 |
+
new_parts.append(tr_seg)
|
| 1031 |
+
cursor = trm.end()
|
| 1032 |
+
continue
|
| 1033 |
+
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
|
| 1034 |
+
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
|
| 1035 |
+
tr_close = "</tr>"
|
| 1036 |
+
inner = inner_m.group(1)
|
| 1037 |
+
cells = [c.group(0) for c in _CELL.finditer(inner)]
|
| 1038 |
+
if i == 0:
|
| 1039 |
+
new_inner = inner + "<th>Balance</th>"
|
| 1040 |
+
new_parts.append(tr_open + new_inner + tr_close)
|
| 1041 |
+
else:
|
| 1042 |
+
if date_idx >= len(cells):
|
| 1043 |
+
new_parts.append(tr_seg)
|
| 1044 |
+
cursor = trm.end()
|
| 1045 |
+
continue
|
| 1046 |
+
d_txt = _cell_plain_text(cells[date_idx])
|
| 1047 |
+
dm = re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or "")
|
| 1048 |
+
bal = dmap.get(dm.group(0)) if dm else None
|
| 1049 |
+
if bal is None and dm:
|
| 1050 |
+
bal = dmap.get("/".join(dm.group(0).split("/")[:2]))
|
| 1051 |
+
if bal is None:
|
| 1052 |
+
new_parts.append(tr_seg)
|
| 1053 |
+
cursor = trm.end()
|
| 1054 |
+
continue
|
| 1055 |
+
new_inner = inner + f"<td>{bal:,.2f}</td>"
|
| 1056 |
+
new_parts.append(tr_open + new_inner + tr_close)
|
| 1057 |
+
added += 1
|
| 1058 |
+
cursor = trm.end()
|
| 1059 |
+
new_parts.append(table[cursor:])
|
| 1060 |
+
if added < 3:
|
| 1061 |
+
return table
|
| 1062 |
+
return "".join(new_parts)
|
| 1063 |
+
|
| 1064 |
+
return re.sub(r"<table\b[^>]*>.*?</table>", repl, md, flags=re.IGNORECASE | re.DOTALL)
|
| 1065 |
+
|
| 1066 |
+
|
| 1067 |
+
def _parse_statement_edge_balances(md: str):
|
| 1068 |
+
start = None
|
| 1069 |
+
end = None
|
| 1070 |
+
m1 = re.search(
|
| 1071 |
+
r"(?:Beginning|Starting)\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 1072 |
+
md,
|
| 1073 |
+
flags=re.IGNORECASE,
|
| 1074 |
+
)
|
| 1075 |
+
if m1:
|
| 1076 |
+
start = _parse_amount_or_none(m1.group(1))
|
| 1077 |
+
m2 = re.search(
|
| 1078 |
+
r"Ending\s+balance[^$\n]{0,80}\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)",
|
| 1079 |
+
md,
|
| 1080 |
+
flags=re.IGNORECASE,
|
| 1081 |
+
)
|
| 1082 |
+
if m2:
|
| 1083 |
+
end = _parse_amount_or_none(m2.group(1))
|
| 1084 |
+
return start, end
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
def synthesize_running_balance_columns(md: str) -> str:
|
| 1088 |
+
"""
|
| 1089 |
+
Fallback when no explicit balance column exists: infer running balances from
|
| 1090 |
+
statement beginning/ending balance and signed transaction amounts.
|
| 1091 |
+
"""
|
| 1092 |
+
if not md or "<table" not in md.lower():
|
| 1093 |
+
return md
|
| 1094 |
+
start_bal, end_bal = _parse_statement_edge_balances(md)
|
| 1095 |
+
if start_bal is None and end_bal is None:
|
| 1096 |
+
return md
|
| 1097 |
+
|
| 1098 |
+
table_re = re.compile(r"<table\b[^>]*>.*?</table>", re.IGNORECASE | re.DOTALL)
|
| 1099 |
+
tr_re = re.compile(r"<tr\b[^>]*>.*?</tr>", re.IGNORECASE | re.DOTALL)
|
| 1100 |
+
|
| 1101 |
+
tables = list(table_re.finditer(md))
|
| 1102 |
+
row_refs = []
|
| 1103 |
+
amounts = []
|
| 1104 |
+
headers = {}
|
| 1105 |
+
|
| 1106 |
+
for ti, tm in enumerate(tables):
|
| 1107 |
+
t = tm.group(0)
|
| 1108 |
+
if re.search(r"(?:colspan|rowspan)\s*=", t, flags=re.IGNORECASE):
|
| 1109 |
+
continue
|
| 1110 |
+
trs = list(tr_re.finditer(t))
|
| 1111 |
+
if len(trs) < 2:
|
| 1112 |
+
continue
|
| 1113 |
+
h_inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trs[0].group(0), flags=re.IGNORECASE | re.DOTALL)
|
| 1114 |
+
if not h_inner_m:
|
| 1115 |
+
continue
|
| 1116 |
+
h_cells = [c.group(0) for c in _CELL.finditer(h_inner_m.group(1))]
|
| 1117 |
+
if not h_cells:
|
| 1118 |
+
continue
|
| 1119 |
+
htxt = [_cell_plain_text(c).strip().lower() for c in h_cells]
|
| 1120 |
+
if any("balance" in h for h in htxt):
|
| 1121 |
+
continue
|
| 1122 |
+
date_idx = next((i for i, h in enumerate(htxt) if "date" in h), -1)
|
| 1123 |
+
amt_idx = next((i for i, h in enumerate(htxt) if "amount" in h or "debit" in h or "credit" in h), -1)
|
| 1124 |
+
if date_idx < 0 or amt_idx < 0:
|
| 1125 |
+
continue
|
| 1126 |
+
headers[ti] = (date_idx, amt_idx)
|
| 1127 |
+
for ri, trm in enumerate(trs[1:], 1):
|
| 1128 |
+
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", trm.group(0), flags=re.IGNORECASE | re.DOTALL)
|
| 1129 |
+
if not inner_m:
|
| 1130 |
+
continue
|
| 1131 |
+
cells = [c.group(0) for c in _CELL.finditer(inner_m.group(1))]
|
| 1132 |
+
if len(cells) <= max(date_idx, amt_idx):
|
| 1133 |
+
continue
|
| 1134 |
+
d_txt = _cell_plain_text(cells[date_idx])
|
| 1135 |
+
if not re.search(r"\d{1,2}/\d{1,2}(?:/\d{2,4})?", d_txt or ""):
|
| 1136 |
+
continue
|
| 1137 |
+
amt = _parse_amount_or_none(_cell_plain_text(cells[amt_idx]))
|
| 1138 |
+
if amt is None:
|
| 1139 |
+
continue
|
| 1140 |
+
row_refs.append((ti, ri))
|
| 1141 |
+
amounts.append(amt)
|
| 1142 |
+
|
| 1143 |
+
if len(amounts) < 20:
|
| 1144 |
+
return md
|
| 1145 |
+
if start_bal is None:
|
| 1146 |
+
start_bal = (end_bal or 0.0) - sum(amounts)
|
| 1147 |
+
|
| 1148 |
+
running = []
|
| 1149 |
+
cur = float(start_bal)
|
| 1150 |
+
for a in amounts:
|
| 1151 |
+
cur += float(a)
|
| 1152 |
+
running.append(cur)
|
| 1153 |
+
bal_by_ref = {row_refs[i]: running[i] for i in range(len(row_refs))}
|
| 1154 |
+
|
| 1155 |
+
pieces = []
|
| 1156 |
+
last = 0
|
| 1157 |
+
for ti, tm in enumerate(tables):
|
| 1158 |
+
pieces.append(md[last : tm.start()])
|
| 1159 |
+
t = tm.group(0)
|
| 1160 |
+
if ti not in headers:
|
| 1161 |
+
pieces.append(t)
|
| 1162 |
+
last = tm.end()
|
| 1163 |
+
continue
|
| 1164 |
+
date_idx, amt_idx = headers[ti]
|
| 1165 |
+
trs = list(tr_re.finditer(t))
|
| 1166 |
+
out_t = []
|
| 1167 |
+
cur2 = 0
|
| 1168 |
+
for idx, trm in enumerate(trs):
|
| 1169 |
+
out_t.append(t[cur2 : trm.start()])
|
| 1170 |
+
tr_seg = trm.group(0)
|
| 1171 |
+
inner_m = re.search(r"<tr\b[^>]*>(.*)</tr>", tr_seg, flags=re.IGNORECASE | re.DOTALL)
|
| 1172 |
+
tr_open_m = re.search(r"<tr\b[^>]*>", tr_seg, flags=re.IGNORECASE)
|
| 1173 |
+
tr_open = tr_open_m.group(0) if tr_open_m else "<tr>"
|
| 1174 |
+
if not inner_m:
|
| 1175 |
+
out_t.append(tr_seg)
|
| 1176 |
+
cur2 = trm.end()
|
| 1177 |
+
continue
|
| 1178 |
+
inner = inner_m.group(1)
|
| 1179 |
+
if idx == 0:
|
| 1180 |
+
out_t.append(tr_open + inner + "<th>Balance</th></tr>")
|
| 1181 |
+
else:
|
| 1182 |
+
b = bal_by_ref.get((ti, idx))
|
| 1183 |
+
if b is None:
|
| 1184 |
+
out_t.append(tr_seg)
|
| 1185 |
+
else:
|
| 1186 |
+
out_t.append(tr_open + inner + f"<td>{b:,.2f}</td></tr>")
|
| 1187 |
+
cur2 = trm.end()
|
| 1188 |
+
out_t.append(t[cur2:])
|
| 1189 |
+
pieces.append("".join(out_t))
|
| 1190 |
+
last = tm.end()
|
| 1191 |
+
pieces.append(md[last:])
|
| 1192 |
+
return "".join(pieces)
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
|
| 1196 |
import pymupdf as fitz
|
| 1197 |
from PIL import Image
|
|
|
|
| 1329 |
merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
|
| 1330 |
if merged and merged != "(No content)" and not merged.lstrip().startswith("Error:"):
|
| 1331 |
merged = stabilize_table_markup(merged, rounds=4)
|
| 1332 |
+
merged = enrich_transaction_tables_with_daily_balances(merged)
|
| 1333 |
+
merged = synthesize_running_balance_columns(merged)
|
| 1334 |
+
merged = stabilize_table_markup(merged, rounds=2)
|
| 1335 |
merged = repair_thead_cell_semantics(merged)
|
| 1336 |
merged = strip_degenerate_html_tables(merged)
|
| 1337 |
return merged
|