super-document-processor 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- super_document_processor/__init__.py +28 -0
- super_document_processor/document_processors/process_docx.py +824 -0
- super_document_processor/document_processors/process_images.py +39 -0
- super_document_processor/document_processors/process_msg.py +62 -0
- super_document_processor/document_processors/process_pdf.py +715 -0
- super_document_processor/document_processors/process_plain_text.py +25 -0
- super_document_processor/document_processors/process_pptx.py +367 -0
- super_document_processor/main.py +826 -0
- super_document_processor/processor.py +135 -0
- super_document_processor/utils/aspose_slide_renderer.py +103 -0
- super_document_processor/utils/chunker.py +127 -0
- super_document_processor/utils/docx_image_utils.py +167 -0
- super_document_processor/utils/docx_xml_parser.py +73 -0
- super_document_processor/utils/optimizer.py +49 -0
- super_document_processor/utils/pdf_image_utils.py +90 -0
- super_document_processor/utils/pdf_table_utils.py +627 -0
- super_document_processor/utils/pptx_components_extract.py +2197 -0
- super_document_processor/utils/pptx_converter.py +170 -0
- super_document_processor/utils/vlm.py +176 -0
- super_document_processor-0.1.3.dist-info/METADATA +376 -0
- super_document_processor-0.1.3.dist-info/RECORD +25 -0
- super_document_processor-0.1.3.dist-info/WHEEL +5 -0
- super_document_processor-0.1.3.dist-info/entry_points.txt +2 -0
- super_document_processor-0.1.3.dist-info/licenses/LICENSE +21 -0
- super_document_processor-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import pymupdf as fitz
|
|
5
|
+
except ImportError:
|
|
6
|
+
import fitz
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def join_multiline_cell(lines: list[str]) -> str:
|
|
10
|
+
"""Combine multiple lines within a single cell using <br>, resolving footnote/superscript artifacts and line wraps."""
|
|
11
|
+
lines = [l.strip() for l in lines if l.strip()]
|
|
12
|
+
if not lines:
|
|
13
|
+
return ""
|
|
14
|
+
if len(lines) == 1:
|
|
15
|
+
return lines[0]
|
|
16
|
+
|
|
17
|
+
# Resolve footnote superscripts on numeric values:
|
|
18
|
+
# e.g. ['1', '46'] or ['6', '46'] where one element is a 1-digit footnote marker and the other is the metric value
|
|
19
|
+
cleaned_nums = [
|
|
20
|
+
re.sub(r"[\s\$,%\(\)\+\-–—]", "", l)
|
|
21
|
+
for l in lines
|
|
22
|
+
]
|
|
23
|
+
if all(cn.replace(".", "", 1).isdigit() for cn in cleaned_nums if cn):
|
|
24
|
+
if len(lines) == 2:
|
|
25
|
+
# If one is a 1-character footnote marker and the other is a full number (>= 2 chars or decimal)
|
|
26
|
+
if len(cleaned_nums[0]) == 1 and len(cleaned_nums[1]) >= 2:
|
|
27
|
+
return lines[1]
|
|
28
|
+
elif len(cleaned_nums[1]) == 1 and len(cleaned_nums[0]) >= 2:
|
|
29
|
+
return lines[0]
|
|
30
|
+
|
|
31
|
+
# Smart line joining: fold mid-sentence wrap continuations, separate bullets/distinct items with <br>
|
|
32
|
+
bullet_starters = ("-", "•", "➢", "*", "+", "–", "—")
|
|
33
|
+
combined_items = []
|
|
34
|
+
|
|
35
|
+
for line in lines:
|
|
36
|
+
if not combined_items:
|
|
37
|
+
combined_items.append(line)
|
|
38
|
+
else:
|
|
39
|
+
prev = combined_items[-1]
|
|
40
|
+
is_bullet = line.startswith(bullet_starters) or bool(re.match(r"^\d+[\.\)]\s+", line))
|
|
41
|
+
prev_is_bullet = prev.startswith(bullet_starters)
|
|
42
|
+
prev_ended_clause = prev.endswith((".", ";", ":", "]", ")", "-", "–", "—"))
|
|
43
|
+
|
|
44
|
+
# If it's a new bullet point, a header/section tag, or after punctuation, separate with <br>
|
|
45
|
+
if is_bullet or prev_ended_clause:
|
|
46
|
+
combined_items.append(line)
|
|
47
|
+
# If previous line was short (e.g. title/date item like '12th Jun –'), keep as separate item with <br>
|
|
48
|
+
elif len(prev) < 20 and not prev_is_bullet:
|
|
49
|
+
combined_items.append(line)
|
|
50
|
+
else:
|
|
51
|
+
# Wrap continuation of existing sentence/paragraph
|
|
52
|
+
combined_items[-1] = f"{prev} {line}"
|
|
53
|
+
|
|
54
|
+
return "<br>".join(combined_items)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_cell_value(val: str) -> str:
|
|
58
|
+
"""Clean PDF extraction artifacts from cell text such as broken numbers, spaced decimals, commas, and parens."""
|
|
59
|
+
if not val:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
v = str(val).replace("\n", " ").replace("|", "¦").strip()
|
|
63
|
+
|
|
64
|
+
# Fix spaced commas in numbers: '3 ,092' -> '3,092'
|
|
65
|
+
v = re.sub(r"(\d+)\s*,\s*(\d+)", r"\1,\2", v)
|
|
66
|
+
|
|
67
|
+
# Fix spaced decimals in numbers: '0 .4' -> '0.4'
|
|
68
|
+
v = re.sub(r"(\d+)\s*\.\s*(\d+)", r"\1.\2", v)
|
|
69
|
+
|
|
70
|
+
# Fix spaced parentheses: '( 0.0)' -> '(0.0)'
|
|
71
|
+
v = re.sub(r"\(\s*([-\d.,%]+)\s*\)", r"(\1)", v)
|
|
72
|
+
|
|
73
|
+
# Fix spaced percent signs: '29 %' -> '29%'
|
|
74
|
+
v = re.sub(r"([-\d.,]+)\s*%", r"\1%", v)
|
|
75
|
+
|
|
76
|
+
# Fix space-split numbers including decimals (e.g. '1 03' -> '103', '1 0.7' -> '10.7', '1 2.7' -> '12.7', '2 61' -> '261')
|
|
77
|
+
if re.match(r"^-?\(?\s*[\d.]+(?:\s+[\d.]+)+\s*\)?%?$", v):
|
|
78
|
+
v = re.sub(r"(?<=\d)\s+(?=[\d.])", "", v)
|
|
79
|
+
v = re.sub(r"(?<=\.)\s+(?=\d)", "", v)
|
|
80
|
+
|
|
81
|
+
return v.strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_complementary_entangled_pair(cell_lines_A: list[list[str]], cell_lines_B: list[list[str]]) -> bool:
|
|
85
|
+
"""Strictly verify if row A and row B form a complementary pair of entangled rows.
|
|
86
|
+
Condition:
|
|
87
|
+
1. Both rows have non-empty Col 0 labels.
|
|
88
|
+
2. In the columns where Row A has 2+ lines, Row B is completely empty.
|
|
89
|
+
3. In the columns where Row B has data, Row A has only 1 line (or is empty).
|
|
90
|
+
4. At least one multi-line column exists in Row A and at least one filled column exists in Row B.
|
|
91
|
+
"""
|
|
92
|
+
if not cell_lines_A or not cell_lines_B:
|
|
93
|
+
return False
|
|
94
|
+
if not (cell_lines_A[0] and cell_lines_B[0]):
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
num_cols = max(len(cell_lines_A), len(cell_lines_B))
|
|
98
|
+
has_split_in_A = False
|
|
99
|
+
has_data_in_B = False
|
|
100
|
+
|
|
101
|
+
for c in range(1, num_cols):
|
|
102
|
+
cl_A = cell_lines_A[c] if c < len(cell_lines_A) else []
|
|
103
|
+
cl_B = cell_lines_B[c] if c < len(cell_lines_B) else []
|
|
104
|
+
|
|
105
|
+
cl_A_filled = [x for x in cl_A if x.strip()]
|
|
106
|
+
cl_B_filled = [x for x in cl_B if x.strip()]
|
|
107
|
+
|
|
108
|
+
if len(cl_A_filled) >= 2:
|
|
109
|
+
has_split_in_A = True
|
|
110
|
+
# Row B must NOT have data in this column (conflict guard)
|
|
111
|
+
if cl_B_filled:
|
|
112
|
+
return False
|
|
113
|
+
elif cl_B_filled:
|
|
114
|
+
has_data_in_B = True
|
|
115
|
+
# Row A must have at most 1 line in this column
|
|
116
|
+
if len(cl_A_filled) > 1:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
return has_split_in_A and has_data_in_B
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def unpack_table_spanning_cells(table: fitz.table.Table, page: fitz.Page) -> list[list]:
|
|
123
|
+
"""Unpack multi-column spanning cells that contain collapsed multi-line records
|
|
124
|
+
using physical PDF word coordinates and column boundaries.
|
|
125
|
+
"""
|
|
126
|
+
raw_rows = table.extract()
|
|
127
|
+
if not raw_rows:
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
has_multiline_spans = any(
|
|
131
|
+
any(c is None for c in r) and any(c is not None and "\n" in str(c) for c in r)
|
|
132
|
+
for r in raw_rows
|
|
133
|
+
)
|
|
134
|
+
if not has_multiline_spans:
|
|
135
|
+
return raw_rows
|
|
136
|
+
|
|
137
|
+
x_cuts = sorted(list({round(c[0], 2) for c in table.cells} | {round(c[2], 2) for c in table.cells}))
|
|
138
|
+
num_cols = len(x_cuts) - 1
|
|
139
|
+
if num_cols != len(raw_rows[0]):
|
|
140
|
+
return raw_rows
|
|
141
|
+
|
|
142
|
+
words = page.get_text("words", clip=fitz.Rect(table.bbox))
|
|
143
|
+
|
|
144
|
+
expanded_rows = []
|
|
145
|
+
for r_idx, r in enumerate(raw_rows):
|
|
146
|
+
spans = []
|
|
147
|
+
c = 0
|
|
148
|
+
while c < len(r):
|
|
149
|
+
val = r[c]
|
|
150
|
+
span = 1
|
|
151
|
+
while c + span < len(r) and r[c + span] is None:
|
|
152
|
+
span += 1
|
|
153
|
+
spans.append((c, span, val))
|
|
154
|
+
c += span
|
|
155
|
+
|
|
156
|
+
if not any(span > 1 and val and "\n" in str(val) for c, span, val in spans):
|
|
157
|
+
expanded_rows.append(r)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
r_y0 = table.rows[r_idx].bbox[1] if (hasattr(table, "rows") and r_idx < len(table.rows)) else table.bbox[1]
|
|
161
|
+
r_y1 = table.rows[r_idx].bbox[3] if (hasattr(table, "rows") and r_idx < len(table.rows)) else table.bbox[3]
|
|
162
|
+
|
|
163
|
+
unfolded_spans = []
|
|
164
|
+
for c_start, span, val in spans:
|
|
165
|
+
if val is None:
|
|
166
|
+
unfolded_spans.append([])
|
|
167
|
+
continue
|
|
168
|
+
lines_text = [l.strip() for l in str(val).split("\n") if l.strip()]
|
|
169
|
+
if span == 1 or len(lines_text) <= 1:
|
|
170
|
+
unfolded_spans.append([[l] for l in lines_text])
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
x0 = x_cuts[c_start] - 1.0
|
|
174
|
+
x1 = x_cuts[c_start + span] + 1.0
|
|
175
|
+
|
|
176
|
+
# Filter words physically residing within this cell's bounding box
|
|
177
|
+
cell_words = [
|
|
178
|
+
w for w in words
|
|
179
|
+
if x0 <= (w[0] + w[2]) / 2 <= x1 and r_y0 - 1.0 <= (w[1] + w[3]) / 2 <= r_y1 + 1.0
|
|
180
|
+
]
|
|
181
|
+
cell_words.sort(key=lambda w: (w[1], w[0]))
|
|
182
|
+
|
|
183
|
+
# Group words by visual line height
|
|
184
|
+
line_groups = []
|
|
185
|
+
for w in cell_words:
|
|
186
|
+
mid_y = (w[1] + w[3]) / 2
|
|
187
|
+
placed = False
|
|
188
|
+
for lg in line_groups:
|
|
189
|
+
if abs(mid_y - lg["y_mid"]) < 4.0:
|
|
190
|
+
lg["words"].append(w)
|
|
191
|
+
lg["y_mid"] = sum((word[1] + word[3]) / 2 for word in lg["words"]) / len(lg["words"])
|
|
192
|
+
placed = True
|
|
193
|
+
break
|
|
194
|
+
if not placed:
|
|
195
|
+
line_groups.append({"y_mid": mid_y, "words": [w]})
|
|
196
|
+
line_groups.sort(key=lambda lg: lg["y_mid"])
|
|
197
|
+
|
|
198
|
+
cell_rows = []
|
|
199
|
+
for l_idx, lg in enumerate(line_groups):
|
|
200
|
+
l_words = sorted(lg["words"], key=lambda w: w[0])
|
|
201
|
+
if l_idx == 0:
|
|
202
|
+
# Top-level banner across the entire spanning span
|
|
203
|
+
slot_row = [""] * span
|
|
204
|
+
slot_row[0] = " ".join(w[4] for w in l_words)
|
|
205
|
+
cell_rows.append(slot_row)
|
|
206
|
+
else:
|
|
207
|
+
# Assign each word to its exact column slot based purely on its physical x-center
|
|
208
|
+
slot_row = [""] * span
|
|
209
|
+
for w in l_words:
|
|
210
|
+
w_mid = (w[0] + w[2]) / 2
|
|
211
|
+
slot = min(
|
|
212
|
+
range(span),
|
|
213
|
+
key=lambda s: 0 if (x_cuts[c_start + s] - 1.0 <= w_mid <= x_cuts[c_start + s + 1] + 1.0)
|
|
214
|
+
else abs(w_mid - (x_cuts[c_start + s] + x_cuts[c_start + s + 1]) / 2)
|
|
215
|
+
)
|
|
216
|
+
if slot_row[slot]:
|
|
217
|
+
slot_row[slot] += " " + w[4]
|
|
218
|
+
else:
|
|
219
|
+
slot_row[slot] = w[4]
|
|
220
|
+
cell_rows.append(slot_row)
|
|
221
|
+
unfolded_spans.append(cell_rows)
|
|
222
|
+
|
|
223
|
+
max_lines = max(len(us) for us in unfolded_spans)
|
|
224
|
+
for line_idx in range(max_lines):
|
|
225
|
+
curr_row = [""] * len(r)
|
|
226
|
+
for s_idx, (c_start, span, val) in enumerate(spans):
|
|
227
|
+
us = unfolded_spans[s_idx]
|
|
228
|
+
if not us:
|
|
229
|
+
continue
|
|
230
|
+
# Col 0 alignment: if Col 0 has 1 fewer line (missing subheader row)
|
|
231
|
+
if c_start == 0 and len(us) == max_lines - 1:
|
|
232
|
+
if line_idx == 0:
|
|
233
|
+
curr_row[0] = us[0][0] if us[0] else ""
|
|
234
|
+
elif line_idx == 1:
|
|
235
|
+
curr_row[0] = ""
|
|
236
|
+
else:
|
|
237
|
+
curr_row[0] = us[line_idx - 1][0] if line_idx - 1 < len(us) and us[line_idx - 1] else ""
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if line_idx < len(us):
|
|
241
|
+
for k in range(min(span, len(us[line_idx]))):
|
|
242
|
+
curr_row[c_start + k] = us[line_idx][k]
|
|
243
|
+
expanded_rows.append(curr_row)
|
|
244
|
+
|
|
245
|
+
return expanded_rows
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def unflatten_multiline_data_rows(rows: list[list]) -> list[list]:
|
|
249
|
+
"""If a table row contains multiple parallel rows of data collapsed into a single
|
|
250
|
+
vector cell block (due to missing horizontal vector lines in the PDF), unflatten them into
|
|
251
|
+
distinct individual rows based strictly on structural geometry.
|
|
252
|
+
"""
|
|
253
|
+
if not rows:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
result = []
|
|
257
|
+
idx = 0
|
|
258
|
+
num_rows = len(rows)
|
|
259
|
+
|
|
260
|
+
while idx < num_rows:
|
|
261
|
+
row = rows[idx]
|
|
262
|
+
cell_lines = []
|
|
263
|
+
for cell in row:
|
|
264
|
+
if cell is None:
|
|
265
|
+
cell_lines.append([])
|
|
266
|
+
else:
|
|
267
|
+
lines = [l.strip() for l in str(cell).split("\n") if l.strip()]
|
|
268
|
+
cell_lines.append(lines)
|
|
269
|
+
|
|
270
|
+
max_lines = max(len(cl) for cl in cell_lines[1:]) if len(cell_lines) > 1 else (len(cell_lines[0]) if cell_lines else 1)
|
|
271
|
+
|
|
272
|
+
if max_lines <= 1:
|
|
273
|
+
result.append(row)
|
|
274
|
+
idx += 1
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Case 1: Col 0 contains all row labels for the multiple rows
|
|
278
|
+
if cell_lines and len(cell_lines[0]) >= max_lines:
|
|
279
|
+
for line_idx in range(max_lines):
|
|
280
|
+
sub_row = []
|
|
281
|
+
for cl in cell_lines:
|
|
282
|
+
val = cl[line_idx] if line_idx < len(cl) else ""
|
|
283
|
+
sub_row.append(val)
|
|
284
|
+
result.append(sub_row)
|
|
285
|
+
idx += 1
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Case 2: Strictly verify if row idx and row idx + 1 are a complementary entangled pair
|
|
289
|
+
if idx + 1 < num_rows:
|
|
290
|
+
next_row = rows[idx + 1]
|
|
291
|
+
next_cell_lines = []
|
|
292
|
+
for cell in next_row:
|
|
293
|
+
if cell is None:
|
|
294
|
+
next_cell_lines.append([])
|
|
295
|
+
else:
|
|
296
|
+
next_cell_lines.append([l.strip() for l in str(cell).split("\n") if l.strip()])
|
|
297
|
+
|
|
298
|
+
if is_complementary_entangled_pair(cell_lines, next_cell_lines):
|
|
299
|
+
num_cols = max(len(cell_lines), len(next_cell_lines))
|
|
300
|
+
row_A = []
|
|
301
|
+
row_B = []
|
|
302
|
+
|
|
303
|
+
for c in range(num_cols):
|
|
304
|
+
cl_A = cell_lines[c] if c < len(cell_lines) else []
|
|
305
|
+
cl_B = next_cell_lines[c] if c < len(next_cell_lines) else []
|
|
306
|
+
|
|
307
|
+
if c == 0:
|
|
308
|
+
row_A.append(cl_A[0] if cl_A else "")
|
|
309
|
+
row_B.append(cl_B[0] if cl_B else "")
|
|
310
|
+
else:
|
|
311
|
+
if len(cl_A) >= 2:
|
|
312
|
+
row_A.append(cl_A[0])
|
|
313
|
+
row_B.append(cl_A[1])
|
|
314
|
+
elif len(cl_A) == 1:
|
|
315
|
+
row_A.append(cl_A[0])
|
|
316
|
+
row_B.append(cl_B[0] if cl_B else "")
|
|
317
|
+
else:
|
|
318
|
+
row_A.append("")
|
|
319
|
+
row_B.append(cl_B[0] if cl_B else "")
|
|
320
|
+
|
|
321
|
+
result.append(row_A)
|
|
322
|
+
result.append(row_B)
|
|
323
|
+
idx += 2
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Otherwise, keep as 1 row so join_multiline_cell handles wrapped paragraphs/bullets
|
|
327
|
+
result.append(row)
|
|
328
|
+
idx += 1
|
|
329
|
+
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def merge_super_headers(rows: list[list[str]]) -> list[list[str]]:
|
|
334
|
+
"""Detect leading 2-tier super headers (where category banners span across columns in Row 0)
|
|
335
|
+
and fuse them with sub-column headers.
|
|
336
|
+
"""
|
|
337
|
+
if len(rows) < 2:
|
|
338
|
+
return rows
|
|
339
|
+
|
|
340
|
+
num_cols = max(len(r) for r in rows)
|
|
341
|
+
norm_rows = [r + [""] * (num_cols - len(r)) for r in rows]
|
|
342
|
+
|
|
343
|
+
r0_filled = [c for c in norm_rows[0] if c.strip() != ""]
|
|
344
|
+
|
|
345
|
+
# Super-headers require at least 2 distinct banners in Row 0 spanning subsets of columns
|
|
346
|
+
if len(r0_filled) < 2 or len(r0_filled) >= num_cols * 0.7:
|
|
347
|
+
return norm_rows
|
|
348
|
+
|
|
349
|
+
header_rows = norm_rows[:2]
|
|
350
|
+
data_rows = norm_rows[2:]
|
|
351
|
+
|
|
352
|
+
# Forward-fill category banners across columns
|
|
353
|
+
filled_header_rows = []
|
|
354
|
+
for h_row in header_rows:
|
|
355
|
+
filled_r = []
|
|
356
|
+
curr = ""
|
|
357
|
+
non_empty = [c for c in h_row if c.strip()]
|
|
358
|
+
is_sparse_banner = (1 <= len(non_empty) < num_cols * 0.7)
|
|
359
|
+
for c in h_row:
|
|
360
|
+
if c.strip():
|
|
361
|
+
curr = c
|
|
362
|
+
filled_r.append(curr if is_sparse_banner else c)
|
|
363
|
+
filled_header_rows.append(filled_r)
|
|
364
|
+
|
|
365
|
+
# Build unified headers column by column
|
|
366
|
+
merged_headers = []
|
|
367
|
+
for col_idx in range(num_cols):
|
|
368
|
+
top_val = filled_header_rows[0][col_idx].strip()
|
|
369
|
+
bot_val = filled_header_rows[1][col_idx].strip()
|
|
370
|
+
|
|
371
|
+
if col_idx == 0:
|
|
372
|
+
# For Col 0, use top_val or bot_val (e.g. 'Particulars (Rs Mn)' or 'Category')
|
|
373
|
+
raw_top = header_rows[0][0].strip()
|
|
374
|
+
raw_bot = header_rows[1][0].strip()
|
|
375
|
+
if raw_top and raw_bot and raw_top != raw_bot:
|
|
376
|
+
if raw_bot.startswith("(") and raw_bot.endswith(")"):
|
|
377
|
+
merged_headers.append(f"{raw_top} {raw_bot}")
|
|
378
|
+
else:
|
|
379
|
+
merged_headers.append(f"{raw_top} ({raw_bot})")
|
|
380
|
+
else:
|
|
381
|
+
merged_headers.append(raw_top or raw_bot)
|
|
382
|
+
else:
|
|
383
|
+
if top_val and bot_val and top_val != bot_val:
|
|
384
|
+
if bot_val.startswith("(") and bot_val.endswith(")"):
|
|
385
|
+
merged_headers.append(f"{top_val} {bot_val}")
|
|
386
|
+
else:
|
|
387
|
+
merged_headers.append(f"{top_val} ({bot_val})")
|
|
388
|
+
else:
|
|
389
|
+
merged_headers.append(top_val or bot_val)
|
|
390
|
+
|
|
391
|
+
return [merged_headers] + data_rows
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def clean_table(rows: list[list]) -> list[list[str]]:
|
|
395
|
+
"""Clean extracted table cells, unflatten collapsed rows, join multiline cells, and drop empty columns."""
|
|
396
|
+
if not rows:
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
rows = unflatten_multiline_data_rows(rows)
|
|
400
|
+
|
|
401
|
+
cleaned_rows = []
|
|
402
|
+
for idx_r, r in enumerate(rows):
|
|
403
|
+
cleaned_row = []
|
|
404
|
+
for cell in r:
|
|
405
|
+
if cell is None:
|
|
406
|
+
val = ""
|
|
407
|
+
else:
|
|
408
|
+
lines = [line.strip() for line in str(cell).split("\n") if line.strip()]
|
|
409
|
+
val = join_multiline_cell(lines)
|
|
410
|
+
|
|
411
|
+
val = normalize_cell_value(val)
|
|
412
|
+
cleaned_row.append(val)
|
|
413
|
+
|
|
414
|
+
# Keep row if it has content or is an empty separator row between data sections
|
|
415
|
+
has_content = any(c.strip() != "" for c in cleaned_row)
|
|
416
|
+
if has_content:
|
|
417
|
+
cleaned_rows.append(cleaned_row)
|
|
418
|
+
elif cleaned_rows and idx_r < len(rows) - 1:
|
|
419
|
+
has_future_content = any(any(str(c or "").strip() != "" for c in next_r) for next_r in rows[idx_r + 1:])
|
|
420
|
+
if has_future_content:
|
|
421
|
+
cleaned_rows.append(cleaned_row)
|
|
422
|
+
|
|
423
|
+
# Fix parenthesis spillover between adjacent columns (e.g., Col0: "(CoM", Col1: ") 7")
|
|
424
|
+
for r in cleaned_rows:
|
|
425
|
+
for idx in range(len(r) - 1):
|
|
426
|
+
if r[idx].endswith("(") or (r[idx].count("(") > r[idx].count(")")):
|
|
427
|
+
if r[idx + 1].startswith(")"):
|
|
428
|
+
r[idx] = (r[idx] + ")").strip()
|
|
429
|
+
r[idx + 1] = r[idx + 1][1:].strip()
|
|
430
|
+
|
|
431
|
+
if not cleaned_rows:
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
num_cols = max(len(r) for r in cleaned_rows)
|
|
435
|
+
matrix = [r + [""] * (num_cols - len(r)) for r in cleaned_rows]
|
|
436
|
+
|
|
437
|
+
# Filter out completely empty columns
|
|
438
|
+
valid_col_indices = [c for c in range(num_cols) if any(matrix[r][c] != "" for r in range(len(matrix)))]
|
|
439
|
+
|
|
440
|
+
if not valid_col_indices:
|
|
441
|
+
return []
|
|
442
|
+
|
|
443
|
+
matrix = [[row[c] for c in valid_col_indices] for row in matrix]
|
|
444
|
+
return merge_super_headers(matrix)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def format_table_with_pipes(rows: list[list[str]]) -> str:
|
|
448
|
+
"""Format cleaned 2D matrix into a markdown pipe-delimited table string with outer border pipes."""
|
|
449
|
+
if not rows:
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
num_cols = len(rows[0])
|
|
453
|
+
headers = rows[0]
|
|
454
|
+
data_rows = rows[1:]
|
|
455
|
+
|
|
456
|
+
col_widths = []
|
|
457
|
+
for col_idx in range(num_cols):
|
|
458
|
+
max_w = max(len(rows[r_idx][col_idx]) if col_idx < len(rows[r_idx]) else 0 for r_idx in range(len(rows)))
|
|
459
|
+
col_widths.append(max(max_w, 3))
|
|
460
|
+
|
|
461
|
+
header_line = "| " + " | ".join(headers[c].ljust(col_widths[c]) for c in range(num_cols)) + " |"
|
|
462
|
+
separator_line = "| " + " | ".join("-" * max(col_widths[c], 3) for c in range(num_cols)) + " |"
|
|
463
|
+
|
|
464
|
+
lines = [header_line, separator_line]
|
|
465
|
+
for row in data_rows:
|
|
466
|
+
row_line = "| " + " | ".join((row[c] if c < len(row) else "").ljust(col_widths[c]) for c in range(num_cols)) + " |"
|
|
467
|
+
lines.append(row_line)
|
|
468
|
+
|
|
469
|
+
return "\n".join(lines)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def split_parallel_tables(rows: list[list[str]]) -> list[list[list[str]]]:
|
|
473
|
+
"""Detect if two independent tables placed side-by-side on the page were erroneously
|
|
474
|
+
merged into a single wide matrix, and separate them into distinct tables.
|
|
475
|
+
Guarded against false positives from section headers, footnotes, and stepped matrices.
|
|
476
|
+
"""
|
|
477
|
+
if not rows or len(rows) < 3:
|
|
478
|
+
return [rows]
|
|
479
|
+
|
|
480
|
+
num_cols = max(len(r) for r in rows)
|
|
481
|
+
if num_cols < 4:
|
|
482
|
+
return [rows]
|
|
483
|
+
|
|
484
|
+
matrix = [r + [""] * (num_cols - len(r)) for r in rows]
|
|
485
|
+
|
|
486
|
+
# Guard 1: If Row 0 is an intact, continuous single header across almost all columns (>= 75%),
|
|
487
|
+
# then it is a single unified table, not two distinct side-by-side tables.
|
|
488
|
+
r0_filled = sum(1 for c in matrix[0] if str(c or "").strip() != "")
|
|
489
|
+
if r0_filled >= num_cols * 0.75:
|
|
490
|
+
return [rows]
|
|
491
|
+
|
|
492
|
+
best_split_col = None
|
|
493
|
+
max_disjoint_score = 0
|
|
494
|
+
|
|
495
|
+
for k in range(2, num_cols - 1):
|
|
496
|
+
left_only_rows = 0
|
|
497
|
+
right_only_rows = 0
|
|
498
|
+
shared_rows = 0
|
|
499
|
+
|
|
500
|
+
for r in matrix:
|
|
501
|
+
has_left = any(str(c or "").strip() != "" for c in r[:k])
|
|
502
|
+
has_right = any(str(c or "").strip() != "" for c in r[k:])
|
|
503
|
+
if has_left and not has_right:
|
|
504
|
+
left_only_rows += 1
|
|
505
|
+
elif has_right and not has_left:
|
|
506
|
+
right_only_rows += 1
|
|
507
|
+
elif has_left and has_right:
|
|
508
|
+
shared_rows += 1
|
|
509
|
+
|
|
510
|
+
total_active_rows = left_only_rows + right_only_rows + shared_rows
|
|
511
|
+
if total_active_rows == 0:
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
if left_only_rows >= 2 and right_only_rows >= 2:
|
|
515
|
+
disjoint_score = (left_only_rows + right_only_rows) / total_active_rows
|
|
516
|
+
if disjoint_score > max_disjoint_score and disjoint_score >= 0.35:
|
|
517
|
+
# Guard 2: Multi-Column Density Guard
|
|
518
|
+
# Ensure both left and right sides contain genuine multi-column records
|
|
519
|
+
# (at least 2 rows with >= 2 filled cells on each side, filtering out 1-cell footnotes)
|
|
520
|
+
left_dense = sum(1 for r in matrix if sum(1 for c in r[:k] if str(c or "").strip() != "") >= 2)
|
|
521
|
+
right_dense = sum(1 for r in matrix if sum(1 for c in r[k:] if str(c or "").strip() != "") >= 2)
|
|
522
|
+
|
|
523
|
+
if left_dense >= 2 and right_dense >= 2:
|
|
524
|
+
max_disjoint_score = disjoint_score
|
|
525
|
+
best_split_col = k
|
|
526
|
+
|
|
527
|
+
if best_split_col is None:
|
|
528
|
+
return [rows]
|
|
529
|
+
|
|
530
|
+
k = best_split_col
|
|
531
|
+
left_raw = [[cell for cell in r[:k]] for r in matrix if any(str(c or "").strip() != "" for c in r[:k])]
|
|
532
|
+
right_raw = [[cell for cell in r[k:]] for r in matrix if any(str(c or "").strip() != "" for c in r[k:])]
|
|
533
|
+
|
|
534
|
+
result = []
|
|
535
|
+
if left_raw and len(left_raw) >= 2:
|
|
536
|
+
result.append(left_raw)
|
|
537
|
+
if right_raw and len(right_raw) >= 2:
|
|
538
|
+
result.append(right_raw)
|
|
539
|
+
|
|
540
|
+
return result if result else [rows]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def get_all_page_tables(page: fitz.Page) -> list:
|
|
544
|
+
"""Find all tables on the page, prioritizing strict vector-line bordered tables over loose heuristic tables,
|
|
545
|
+
while selecting heuristic tables only when vector lines suffered row/header collapse.
|
|
546
|
+
"""
|
|
547
|
+
strict_tables = list(page.find_tables(strategy="lines_strict").tables)
|
|
548
|
+
def_tables = list(page.find_tables().tables)
|
|
549
|
+
|
|
550
|
+
chosen_tables = []
|
|
551
|
+
handled_def_indices = set()
|
|
552
|
+
|
|
553
|
+
for s_tab in strict_tables:
|
|
554
|
+
s_rect = fitz.Rect(s_tab.bbox)
|
|
555
|
+
overlapping_def_idx = None
|
|
556
|
+
for d_idx, d_tab in enumerate(def_tables):
|
|
557
|
+
if d_idx in handled_def_indices:
|
|
558
|
+
continue
|
|
559
|
+
d_rect = fitz.Rect(d_tab.bbox)
|
|
560
|
+
intersection = s_rect & d_rect
|
|
561
|
+
if intersection.is_valid and intersection.get_area() > 0.3 * min(s_rect.get_area(), d_rect.get_area()):
|
|
562
|
+
overlapping_def_idx = d_idx
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
if overlapping_def_idx is not None:
|
|
566
|
+
handled_def_indices.add(overlapping_def_idx)
|
|
567
|
+
d_tab = def_tables[overlapping_def_idx]
|
|
568
|
+
# Only prefer heuristic detection if strict lines completely failed to segment columns
|
|
569
|
+
if s_tab.col_count <= 1 < d_tab.col_count:
|
|
570
|
+
chosen_tables.append(d_tab)
|
|
571
|
+
else:
|
|
572
|
+
chosen_tables.append(s_tab)
|
|
573
|
+
else:
|
|
574
|
+
chosen_tables.append(s_tab)
|
|
575
|
+
|
|
576
|
+
for d_idx, d_tab in enumerate(def_tables):
|
|
577
|
+
if d_idx not in handled_def_indices:
|
|
578
|
+
chosen_tables.append(d_tab)
|
|
579
|
+
|
|
580
|
+
chosen_tables.sort(key=lambda t: (t.bbox[1], t.bbox[0]))
|
|
581
|
+
return chosen_tables
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def is_genuine_table(cleaned_rows: list[list[str]], raw_table) -> bool:
|
|
585
|
+
"""Validate that the extracted matrix represents a genuine structured table and not a false-positive
|
|
586
|
+
callout box, decorative border, or single paragraph container.
|
|
587
|
+
"""
|
|
588
|
+
if not cleaned_rows or len(cleaned_rows) < 2:
|
|
589
|
+
return False
|
|
590
|
+
if len(cleaned_rows[0]) < 2:
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
raw = (raw_table.extract() if hasattr(raw_table, "extract") else None) or []
|
|
594
|
+
if len(raw) <= 2:
|
|
595
|
+
non_empty_per_row = [sum(1 for c in r if c and str(c).strip()) for r in raw]
|
|
596
|
+
if min(non_empty_per_row, default=0) <= 1 and max(non_empty_per_row, default=0) <= 2:
|
|
597
|
+
total_chars = sum(len(str(c or "")) for r in raw for c in r)
|
|
598
|
+
total_cells = sum(non_empty_per_row)
|
|
599
|
+
if total_cells <= 3 and total_chars > 200:
|
|
600
|
+
return False
|
|
601
|
+
|
|
602
|
+
return True
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def extract_tables_from_pdf_page(tables: list, page: fitz.Page, page_num: int) -> list:
|
|
606
|
+
"""Extract and format tables from a PDF page using PyMuPDF native table extraction."""
|
|
607
|
+
found_tables = get_all_page_tables(page)
|
|
608
|
+
for table in found_tables:
|
|
609
|
+
raw_rows = unpack_table_spanning_cells(table, page)
|
|
610
|
+
if not raw_rows:
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
sub_tables_raw = split_parallel_tables(raw_rows)
|
|
614
|
+
for sub_raw in sub_tables_raw:
|
|
615
|
+
cleaned_rows = clean_table(sub_raw)
|
|
616
|
+
if not is_genuine_table(cleaned_rows, table):
|
|
617
|
+
continue
|
|
618
|
+
table_text = format_table_with_pipes(cleaned_rows)
|
|
619
|
+
if not table_text:
|
|
620
|
+
continue
|
|
621
|
+
table_bbox = fitz.Rect(table.bbox)
|
|
622
|
+
tables.append({
|
|
623
|
+
"bbox": table_bbox,
|
|
624
|
+
"text": table_text,
|
|
625
|
+
"page": page_num,
|
|
626
|
+
})
|
|
627
|
+
return tables
|