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.
@@ -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