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,715 @@
1
+ import os
2
+ import re
3
+ import logging
4
+ import threading
5
+ import concurrent.futures
6
+ from collections import defaultdict
7
+
8
+ logger = logging.getLogger("super_document_processor.pdf")
9
+
10
+ os.environ["PYMUPDF_SUGGEST_LAYOUT_ANALYZER"] = "0"
11
+ try:
12
+ import pymupdf
13
+ fitz = pymupdf
14
+ try:
15
+ pymupdf.no_recommend_layout()
16
+ except Exception:
17
+ pass
18
+ except ImportError:
19
+ import fitz
20
+
21
+ try:
22
+ fitz.TOOLS.mupdf_display_errors(False)
23
+ except Exception:
24
+ pass
25
+ try:
26
+ from super_document_processor.utils.pdf_table_utils import extract_tables_from_pdf_page
27
+ from super_document_processor.utils.pdf_image_utils import process_image
28
+ except ImportError:
29
+ from utils.pdf_table_utils import extract_tables_from_pdf_page
30
+ from utils.pdf_image_utils import process_image
31
+
32
+
33
+ def cluster_spans_into_physical_lines(page, tables, links=None):
34
+ """
35
+ Extracts text lines and spans from the page, preserving:
36
+ 1. Superscripts and intra-word span connections (e.g. '1' + 'st' -> '1st', '22.' + '5%' -> '22.5%').
37
+ 2. Proper horizontal column segmentation.
38
+ 3. In-place hyperlinks (both external URLs and internal slide/page jumps).
39
+ """
40
+ d = page.get_text('dict')
41
+ raw_lines = []
42
+
43
+ for b in d.get('blocks', []):
44
+ if 'lines' not in b:
45
+ continue
46
+
47
+ b_lines = b['lines']
48
+ for l in b_lines:
49
+ spans = l.get('spans', [])
50
+ if not spans:
51
+ continue
52
+
53
+ # Join spans within the same line while preserving native spaces
54
+ line_parts = []
55
+ cur_line_rect = None
56
+
57
+ for s in spans:
58
+ s_txt = s.get('text', '')
59
+ if not s_txt:
60
+ continue
61
+ s_bbox = fitz.Rect(s['bbox'])
62
+
63
+ # Check if this span intersects with an external or internal link
64
+ if links:
65
+ for lk in links:
66
+ lk_rect = lk['bbox']
67
+ inter_rect = s_bbox & lk_rect
68
+ if inter_rect.is_valid and s_bbox.get_area() > 0:
69
+ if (inter_rect.get_area() / s_bbox.get_area()) > 0.40:
70
+ target = lk.get('target', '')
71
+ clean_stxt = s_txt.strip()
72
+ if clean_stxt and target:
73
+ if clean_stxt.startswith(('http://', 'https://', 'www.')):
74
+ s_txt = s_txt.replace(clean_stxt, f"[{target}]")
75
+ else:
76
+ s_txt = s_txt.replace(clean_stxt, f"{clean_stxt} → [{target}]")
77
+ break
78
+
79
+ if cur_line_rect is None:
80
+ cur_line_rect = s_bbox
81
+ line_parts.append((s_bbox, s_txt))
82
+ else:
83
+ prev_bbox, prev_txt = line_parts[-1]
84
+ gap_x = s_bbox.x0 - prev_bbox.x1
85
+
86
+ prev_has_space = prev_txt.endswith((' ', '\t'))
87
+ s_has_space = s_txt.startswith((' ', '\t'))
88
+
89
+ clean_s = s_txt.strip()
90
+ # Decimal continuation: '22.' + '5%' -> '22.5%'
91
+ is_decimal_continuation = bool(re.search(r'\d+\.$', prev_txt.rstrip())) and bool(re.match(r'^\d', clean_s))
92
+ # Suffix check (e.g. digit + 'st', 'nd', 'rd', 'th', '%', '™', '®', '©')
93
+ is_suffix = (
94
+ (bool(re.search(r'\d$', prev_txt.rstrip())) or bool(re.search(r'\d+\.$', prev_txt.rstrip()))) and (
95
+ clean_s.lower() in ('st', 'nd', 'rd', 'th', '%', '™', '®', '©', '°c', '°f', 'k', 'm', 'bn', 'cr', 'x') or
96
+ bool(re.match(r'^(st|nd|rd|th)[,\.;:\s]', clean_s, re.IGNORECASE)) or
97
+ clean_s.startswith(('%', '™', '®', '©'))
98
+ )
99
+ )
100
+
101
+ if is_decimal_continuation or is_suffix:
102
+ combined_txt = prev_txt.rstrip() + s_txt.lstrip()
103
+ elif prev_has_space or s_has_space:
104
+ combined_txt = prev_txt.rstrip() + " " + s_txt.lstrip()
105
+ elif gap_x > 2.0:
106
+ combined_txt = prev_txt + " " + s_txt
107
+ else:
108
+ combined_txt = prev_txt + s_txt
109
+
110
+ combined_bbox = prev_bbox | s_bbox
111
+ line_parts[-1] = (combined_bbox, combined_txt)
112
+ cur_line_rect = cur_line_rect | s_bbox
113
+
114
+ if line_parts:
115
+ seg_text = line_parts[0][1].strip()
116
+ if not seg_text:
117
+ continue
118
+
119
+ # Table containment filter:
120
+ # If more than 60% of the line is covered by a table, discard it (it belongs inside the table).
121
+ # If at least 40% of the line is visible outside the table (e.g. headers, captions), preserve and show it.
122
+ line_area = cur_line_rect.get_area()
123
+ if line_area > 0:
124
+ is_inside_table = any(
125
+ (cur_line_rect & t['bbox']).is_valid and
126
+ ((cur_line_rect & t['bbox']).get_area() / line_area) > 0.60
127
+ for t in tables
128
+ )
129
+ if is_inside_table:
130
+ continue
131
+
132
+ # Clean up spaced abbreviations with periods on both letters (e.g. 'e. g.' -> 'e.g.') or 'w.r.t'
133
+ seg_text = re.sub(r'\b([a-zA-Z]\.)\s+([a-zA-Z]\.)', r'\1\2', seg_text)
134
+ seg_text = re.sub(r'\b(w\.)\s*(r\.)?\s*(t\b)', lambda m: 'W.R.T' if m.group(0).isupper() else 'w.r.t', seg_text, flags=re.IGNORECASE)
135
+ seg_text = re.sub(r'(\d+)\s*\.\s*(\d+)', r'\1.\2', seg_text)
136
+ seg_text = re.sub(r'(\d+)\s+%', r'\1%', seg_text)
137
+ raw_lines.append({
138
+ 'bbox': cur_line_rect,
139
+ 'text': seg_text,
140
+ 'y_mid': (cur_line_rect.y0 + cur_line_rect.y1) / 2.0
141
+ })
142
+
143
+ if not raw_lines:
144
+ return []
145
+
146
+ # Sort lines by vertical position
147
+ raw_lines.sort(key=lambda l: (l['y_mid'], l['bbox'].x0))
148
+
149
+ # Cluster horizontally aligned lines that share the same baseline into segments
150
+ clustered_lines = []
151
+ cur_line_group = [raw_lines[0]]
152
+ cur_y = raw_lines[0]['y_mid']
153
+
154
+ for l in raw_lines[1:]:
155
+ if abs(l['y_mid'] - cur_y) <= 4.0:
156
+ cur_line_group.append(l)
157
+ else:
158
+ clustered_lines.append(cur_line_group)
159
+ cur_line_group = [l]
160
+ cur_y = l['y_mid']
161
+ if cur_line_group:
162
+ clustered_lines.append(cur_line_group)
163
+
164
+ line_segments = []
165
+ for line_group in clustered_lines:
166
+ line_group.sort(key=lambda item: item['bbox'].x0)
167
+
168
+ cur_seg = [line_group[0]]
169
+ cur_right = line_group[0]['bbox'].x1
170
+
171
+ for item in line_group[1:]:
172
+ lead_txt = cur_seg[0]['text'].strip() if cur_seg else ""
173
+ is_bullet_leader = len(cur_seg) == 1 and bool(
174
+ re.match(r'^([•*○◦▪▫■□◆◇◈❖➢➤➔➜►▶→⇒»›✓✔★✦✧\-\u2013\u2014\uf0b7\u27a2\u25cf\uf06f\uf0a7\uf0a8\uf0d8\uf0fc\uf075]|o\s+|\d{1,3}[.)]|\([a-zA-Z0-9]{1,3}\)|\[\d{1,3}\]|(?!(?:e\.g\.|i\.e\.|etc\.|vs\.)\b)[a-zA-Z][.)]|(?:[ivxIVX]+)[.)])$', lead_txt)
175
+ )
176
+ split_gap = 35.0 if is_bullet_leader else 20.0
177
+
178
+ if item['bbox'].x0 - cur_right >= split_gap:
179
+ seg_rect = cur_seg[0]['bbox']
180
+ for cs in cur_seg[1:]:
181
+ seg_rect = seg_rect | cs['bbox']
182
+ seg_text = " ".join(cs['text'] for cs in cur_seg)
183
+ line_segments.append({'bbox': seg_rect, 'text': seg_text})
184
+ cur_seg = [item]
185
+ cur_right = item['bbox'].x1
186
+ else:
187
+ cur_seg.append(item)
188
+ cur_right = max(cur_right, item['bbox'].x1)
189
+
190
+ if cur_seg:
191
+ seg_rect = cur_seg[0]['bbox']
192
+ for cs in cur_seg[1:]:
193
+ seg_rect = seg_rect | cs['bbox']
194
+ seg_text = " ".join(cs['text'] for cs in cur_seg)
195
+ line_segments.append({'bbox': seg_rect, 'text': seg_text})
196
+
197
+ return line_segments
198
+
199
+
200
+ def group_line_segments_into_paragraphs(line_segments, page_height=None):
201
+ """
202
+ Groups vertically stacked line segments in the same column into paragraph blocks,
203
+ preserving sub-bullet nesting and indentation.
204
+ """
205
+ if not line_segments:
206
+ return []
207
+
208
+ line_segments.sort(key=lambda l: (l['bbox'].y0, l['bbox'].x0))
209
+
210
+ PUA_GLYPH_MAP = {
211
+ '\uf0b7': '•',
212
+ '\uf06f': 'o',
213
+ '\uf0a7': '▪',
214
+ '\uf0a8': '▫',
215
+ '\uf0d8': '➢',
216
+ '\uf0fc': '✔',
217
+ '\uf075': '◆',
218
+ '\u25cf': '•',
219
+ }
220
+
221
+ # Universal bullet pattern for any list marker (symbols, letters, numbers, roman numerals)
222
+ # Strictly requires whitespace after hyphens/numbers/letters and excludes negative numbers, decimals, metrics, and names
223
+ ALL_BULLETS_PATTERN = (
224
+ r'^(?:([•*➢➤➔➜►▶→⇒»›✓✔★✦✧\u25cf\u25a0\u25a1\u25aa\u25ab\u25cb\u25e6\u25c6\u25c7\u25c8\u2756\u2219'
225
+ r'\uf0b7\u27a2\uf0d8\uf0fc\uf06f\uf0a7\uf0a8\uf075]|[-–—]\s+|o\s+|\d{1,3}[.)]\s+(?!\d|%|k\b|m\b|cr\b|bn\b|lakh\b|x\b|\$)|'
226
+ r'\([a-zA-Z0-9]{1,3}\)\s+|\[\d{1,3}\]\s+|'
227
+ r'(?!(?:e\.g\.|i\.e\.|etc\.|vs\.|w\.r\.t\.)\b)[a-z][.)]\s+|[A-Z][.)]\s+(?=[A-Z0-9])|(?:[ivxIVX]+)[.)]\s+)\s*)(.*)$'
228
+ )
229
+
230
+ processed = []
231
+ for l in line_segments:
232
+ txt = l['text'].strip()
233
+ m_bullet = re.match(ALL_BULLETS_PATTERN, txt)
234
+
235
+ if m_bullet:
236
+ raw_glyph = m_bullet.group(1).strip()
237
+ glyph = PUA_GLYPH_MAP.get(raw_glyph, raw_glyph)
238
+ rest = m_bullet.group(2).strip()
239
+ processed.append({
240
+ 'bbox': l['bbox'],
241
+ 'glyph': glyph,
242
+ 'rest': rest,
243
+ 'is_bullet': True,
244
+ 'raw': txt
245
+ })
246
+ else:
247
+ processed.append({
248
+ 'bbox': l['bbox'],
249
+ 'glyph': '',
250
+ 'rest': txt,
251
+ 'is_bullet': False,
252
+ 'raw': txt
253
+ })
254
+
255
+ blocks = []
256
+ for item in processed:
257
+ is_bullet = item['is_bullet']
258
+ raw_txt = item['raw']
259
+ is_in_margin = (page_height is None) or (item['bbox'].y0 >= page_height * 0.85 or item['bbox'].y1 <= page_height * 0.10)
260
+ is_page_num = is_in_margin and bool(re.match(r'^(?:\d{1,4}|page\s+\d+(?:\s+of\s+\d+)?|slide\s+\d+(?:\s+of\s+\d+)?)$', raw_txt.strip(), re.IGNORECASE))
261
+
262
+ if is_page_num:
263
+ blocks.append({
264
+ 'bbox': item['bbox'],
265
+ 'base_x0': item['bbox'].x0,
266
+ 'lines': [raw_txt.strip()],
267
+ 'type': 'page_number'
268
+ })
269
+ continue
270
+
271
+ if not blocks:
272
+ if is_bullet:
273
+ glyph, rest = item['glyph'], item['rest']
274
+ line_str = f"{glyph} {rest}" if rest else glyph
275
+ bullet_x0 = item['bbox'].x0
276
+ else:
277
+ line_str = raw_txt
278
+ bullet_x0 = None
279
+
280
+ blocks.append({
281
+ 'bbox': item['bbox'],
282
+ 'base_x0': item['bbox'].x0,
283
+ 'bullet_x0': bullet_x0,
284
+ 'lines': [line_str],
285
+ 'type': 'text'
286
+ })
287
+ continue
288
+
289
+ attached = False
290
+ for b in reversed(blocks):
291
+ if b.get('type') == 'page_number':
292
+ continue
293
+ y_gap = item['bbox'].y0 - b['bbox'].y1
294
+ x_overlap = min(b['bbox'].x1, item['bbox'].x1) - max(b['bbox'].x0, item['bbox'].x0)
295
+ starts_within_column = item['bbox'].x0 >= (b['bbox'].x0 - 8.0) and (x_overlap > 0 or item['bbox'].x0 <= b['bbox'].x0 + 50.0)
296
+ is_left_aligned = abs(b['bbox'].x0 - item['bbox'].x0) <= 25.0
297
+
298
+ if is_bullet:
299
+ # Coordinate-driven bullet nesting: if within column and directly below
300
+ if -2.0 <= y_gap <= 12.0 and starts_within_column:
301
+ if b.get('bullet_x0') is None:
302
+ b['bullet_x0'] = item['bbox'].x0
303
+
304
+ diff = item['bbox'].x0 - b['bullet_x0']
305
+ if diff < -5.0:
306
+ b['bullet_x0'] = item['bbox'].x0
307
+ indent_str = ""
308
+ elif diff >= 18.0:
309
+ indent_str = " "
310
+ elif diff >= 6.0:
311
+ indent_str = " "
312
+ else:
313
+ indent_str = ""
314
+
315
+ glyph, rest = item['glyph'], item['rest']
316
+ formatted_line = f"{indent_str}{glyph} {rest}" if rest else f"{indent_str}{glyph}"
317
+
318
+ b['lines'].append(formatted_line)
319
+ b['bbox'] = b['bbox'] | item['bbox']
320
+ attached = True
321
+ break
322
+
323
+ else:
324
+ # Continuation of contiguous lines within the exact same paragraph (word wraps)
325
+ max_wrap_gap = 6.0
326
+ if -2.0 <= y_gap <= max_wrap_gap and starts_within_column and (x_overlap > 0 or is_left_aligned):
327
+ b['lines'].append(raw_txt)
328
+ b['bbox'] = b['bbox'] | item['bbox']
329
+ attached = True
330
+ break
331
+
332
+ if not attached:
333
+ if is_bullet:
334
+ glyph, rest = item['glyph'], item['rest']
335
+ line_str = f"{glyph} {rest}" if rest else glyph
336
+ bullet_x0 = item['bbox'].x0
337
+ else:
338
+ line_str = raw_txt
339
+ bullet_x0 = None
340
+
341
+ blocks.append({
342
+ 'bbox': item['bbox'],
343
+ 'base_x0': item['bbox'].x0,
344
+ 'bullet_x0': bullet_x0,
345
+ 'lines': [line_str],
346
+ 'type': 'text'
347
+ })
348
+
349
+ for b in blocks:
350
+ reflowed = []
351
+ for line in b['lines']:
352
+ stripped_line = line.strip()
353
+ is_new_bullet = bool(re.match(ALL_BULLETS_PATTERN, stripped_line))
354
+ prev_is_heading = reflowed and reflowed[-1].rstrip().endswith(':')
355
+ is_sub_bullet = line.startswith((' ', '\t')) and is_new_bullet
356
+
357
+ if not reflowed or is_new_bullet or prev_is_heading or is_sub_bullet:
358
+ reflowed.append(line)
359
+ else:
360
+ prev = reflowed[-1]
361
+ if prev.endswith('-'):
362
+ reflowed[-1] = prev[:-1] + stripped_line
363
+ else:
364
+ reflowed[-1] = f"{prev} {stripped_line}"
365
+
366
+ b['text'] = "\n".join(reflowed)
367
+ b['raw_text'] = b['text']
368
+
369
+ return blocks
370
+
371
+
372
+ def order_layout_elements(elements, min_gutter_x=12.0, min_band_gap_y=16.0):
373
+ """
374
+ Robust Hierarchical Layout Ordering (Recursive XY-Cut):
375
+ 1. Separate full-width spanning headers at top and footers/notes at bottom.
376
+ 2. Split distinct vertical columns (Left -> Right) if a gutter exists.
377
+ 3. Split distinct horizontal bands (Rows) if a horizontal valley exists.
378
+ 4. Order top-to-bottom within each final cell.
379
+ """
380
+ if len(elements) <= 1:
381
+ return elements
382
+
383
+ min_x = min(e['bbox'].x0 for e in elements)
384
+ max_x = max(e['bbox'].x1 for e in elements)
385
+ total_span_x = max_x - min_x
386
+
387
+ sorted_by_y = sorted(elements, key=lambda e: (e['bbox'].y0, e['bbox'].x0))
388
+
389
+ # 1a. Check for spanning Header at the top
390
+ top_elem = sorted_by_y[0]
391
+ top_elem_width = top_elem['bbox'].width
392
+ if top_elem_width >= 0.4 * total_span_x and len(sorted_by_y) > 1:
393
+ y_gap = sorted_by_y[1]['bbox'].y0 - top_elem['bbox'].y1
394
+ if y_gap >= 8.0:
395
+ rest = [e for e in elements if e is not top_elem]
396
+ return [top_elem] + order_layout_elements(rest, min_gutter_x, min_band_gap_y)
397
+
398
+ # 1b. Check for spanning Footer / bottom footnote
399
+ bottom_elem = sorted_by_y[-1]
400
+ bottom_elem_width = bottom_elem['bbox'].width
401
+ if bottom_elem_width >= 0.4 * total_span_x and len(sorted_by_y) > 1:
402
+ other_max_y1 = max(e['bbox'].y1 for e in sorted_by_y[:-1])
403
+ y_gap = bottom_elem['bbox'].y0 - other_max_y1
404
+ if y_gap >= 8.0:
405
+ rest = [e for e in elements if e is not bottom_elem]
406
+ return order_layout_elements(rest, min_gutter_x, min_band_gap_y) + [bottom_elem]
407
+
408
+ # 2. Check for vertical column split (gutters across the current region)
409
+ sorted_by_x = sorted(elements, key=lambda e: (e['bbox'].x0, e['bbox'].y0))
410
+ columns = []
411
+ current_col = [sorted_by_x[0]]
412
+ current_right = sorted_by_x[0]['bbox'].x1
413
+
414
+ for e in sorted_by_x[1:]:
415
+ if e['bbox'].x0 >= current_right + min_gutter_x:
416
+ columns.append(current_col)
417
+ current_col = [e]
418
+ current_right = e['bbox'].x1
419
+ else:
420
+ current_col.append(e)
421
+ current_right = max(current_right, e['bbox'].x1)
422
+ if current_col:
423
+ columns.append(current_col)
424
+
425
+ if len(columns) > 1:
426
+ ordered = []
427
+ for col in columns:
428
+ ordered.extend(order_layout_elements(col, min_gutter_x, min_band_gap_y))
429
+ return ordered
430
+
431
+ # 3. Check for horizontal band split (distinct rows across all columns)
432
+ y_cuts = []
433
+ current_bottom = sorted_by_y[0]['bbox'].y1
434
+ current_group = [sorted_by_y[0]]
435
+
436
+ for e in sorted_by_y[1:]:
437
+ if e['bbox'].y0 >= current_bottom + min_band_gap_y:
438
+ y_cuts.append(current_group)
439
+ current_group = [e]
440
+ current_bottom = e['bbox'].y1
441
+ else:
442
+ current_group.append(e)
443
+ current_bottom = max(current_bottom, e['bbox'].y1)
444
+ if current_group:
445
+ y_cuts.append(current_group)
446
+
447
+ if len(y_cuts) > 1:
448
+ result = []
449
+ for group in y_cuts:
450
+ result.extend(order_layout_elements(group, min_gutter_x, min_band_gap_y))
451
+ return result
452
+
453
+ return sorted(elements, key=lambda e: (e['bbox'].y0, e['bbox'].x0))
454
+
455
+
456
+ def extract_links_from_page(page):
457
+ """Extract links from a page and return them with bounding boxes for positioning."""
458
+ links = []
459
+ try:
460
+ link_list = page.get_links()
461
+ for link in link_list:
462
+ rect = fitz.Rect(link['from'])
463
+ target = None
464
+
465
+ # 1. External Web URL
466
+ if 'uri' in link and link['uri']:
467
+ target = link['uri']
468
+ # 2. Internal Page / Slide Jump
469
+ elif 'page' in link and link['page'] is not None and link['page'] >= 0:
470
+ target_page = link['page'] + 1
471
+ target = f"Slide {target_page}"
472
+
473
+ if not target:
474
+ continue
475
+
476
+ link_text = page.get_textbox(rect).strip()
477
+ if not link_text:
478
+ expanded_rect = rect + (-5, -5, 5, 5)
479
+ link_text = page.get_textbox(expanded_rect).strip()
480
+
481
+ if not link_text:
482
+ link_text = "Link"
483
+
484
+ if link_text.startswith(('http://', 'https://', 'www.')):
485
+ formatted_link = f"[{target}]"
486
+ else:
487
+ formatted_link = f"{link_text} → [{target}]"
488
+
489
+ links.append({
490
+ 'bbox': rect,
491
+ 'target': target,
492
+ 'link_text': link_text,
493
+ 'text': formatted_link,
494
+ 'raw_text': formatted_link,
495
+ 'type': 'link'
496
+ })
497
+
498
+ except Exception as e:
499
+ logger.warning(f"Could not extract links: {e}")
500
+
501
+ return links
502
+
503
+
504
+ def get_drawing_boxes(page, tables):
505
+ """
506
+ Extracts visual container/card boxes from vector drawings on the page.
507
+ Filters out full-page backgrounds, tiny decorative lines, and table bounding boxes.
508
+ """
509
+ page_rect = page.rect
510
+ raw_boxes = []
511
+ for d in page.get_drawings():
512
+ r = fitz.Rect(d['rect'])
513
+ if r.width >= page_rect.width - 20 and r.height >= page_rect.height - 20:
514
+ continue
515
+ if r.width >= 50 and r.height >= 25:
516
+ # Exclude boxes that intersect heavily with detected tables
517
+ if any((r & t['bbox']).is_valid and (r & t['bbox']).get_area() > 0.4 * min(r.get_area(), t['bbox'].get_area()) for t in tables):
518
+ continue
519
+ raw_boxes.append(r)
520
+
521
+ # Deduplicate nearly identical boxes (e.g. stroke + fill pairs for the same card)
522
+ unique_boxes = []
523
+ for b in raw_boxes:
524
+ if not any(abs(ub.x0 - b.x0) < 3 and abs(ub.y0 - b.y0) < 3 and abs(ub.x1 - b.x1) < 3 and abs(ub.y1 - b.y1) < 3 for ub in unique_boxes):
525
+ unique_boxes.append(b)
526
+
527
+ # If an outer container encloses multiple smaller card boxes, keep the individual card boxes
528
+ clean_boxes = []
529
+ for b in unique_boxes:
530
+ children = [other for other in unique_boxes if other != b and b.contains(other) and other.get_area() < 0.8 * b.get_area()]
531
+ if len(children) >= 2:
532
+ continue
533
+ clean_boxes.append(b)
534
+
535
+ return clean_boxes
536
+
537
+
538
+ def apply_box_decorations(ordered_elements, drawing_boxes):
539
+ """
540
+ Identifies elements enclosed inside drawing boxes / callouts and frames them with '---'
541
+ without creating consecutive duplicate separators.
542
+ """
543
+ if not drawing_boxes:
544
+ return ordered_elements
545
+
546
+ decorated = []
547
+ current_box_elements = []
548
+ active_box = None
549
+
550
+ for el in ordered_elements:
551
+ el_rect = el['bbox']
552
+ matching_box = None
553
+ # Standalone page numbers belong to the global footer and sit outside card containers
554
+ if el.get('type') != 'page_number':
555
+ for box in drawing_boxes:
556
+ el_center_x = (el_rect.x0 + el_rect.x1) / 2.0
557
+ el_center_y = (el_rect.y0 + el_rect.y1) / 2.0
558
+ if box.x0 - 5 <= el_center_x <= box.x1 + 5 and box.y0 - 5 <= el_center_y <= box.y1 + 5:
559
+ matching_box = box
560
+ break
561
+
562
+ if matching_box != active_box:
563
+ if active_box is not None and current_box_elements:
564
+ decorated.append({'type': 'separator', 'text': '---', 'bbox': active_box})
565
+ decorated.extend(current_box_elements)
566
+ decorated.append({'type': 'separator', 'text': '---', 'bbox': active_box})
567
+ current_box_elements = []
568
+ active_box = matching_box
569
+
570
+ if active_box is not None:
571
+ current_box_elements.append(el)
572
+ else:
573
+ decorated.append(el)
574
+
575
+ if active_box is not None and current_box_elements:
576
+ decorated.append({'type': 'separator', 'text': '---', 'bbox': active_box})
577
+ decorated.extend(current_box_elements)
578
+ decorated.append({'type': 'separator', 'text': '---', 'bbox': active_box})
579
+
580
+ # Deduplicate consecutive '---' separators
581
+ clean_decorated = []
582
+ for e in decorated:
583
+ if e.get('text', '').strip() == '---':
584
+ if clean_decorated and clean_decorated[-1].get('text', '').strip() == '---':
585
+ continue
586
+ clean_decorated.append(e)
587
+
588
+ return clean_decorated
589
+
590
+
591
+ def format_page_elements(page, tables, links, image_pieces=None):
592
+ """
593
+ Extracts text spans, tables, links, and image descriptions and arranges them in true 2D reading order.
594
+ """
595
+ page_height = page.rect.height if page else None
596
+ drawing_boxes = get_drawing_boxes(page, tables)
597
+ segs = cluster_spans_into_physical_lines(page, tables, links=links)
598
+ text_blocks = group_line_segments_into_paragraphs(segs, page_height=page_height)
599
+
600
+ elements = list(text_blocks)
601
+
602
+ for table in tables:
603
+ elements.append({
604
+ 'bbox': table["bbox"],
605
+ 'text': table["text"],
606
+ 'raw_text': table["text"],
607
+ 'type': 'table'
608
+ })
609
+
610
+ # For any standalone links that were NOT covered by existing text spans, append them
611
+ existing_boxes = [e['bbox'] for e in elements]
612
+ for link in links:
613
+ is_covered = any(
614
+ (link['bbox'] & eb).is_valid and (link['bbox'] & eb).get_area() > 0.40 * link['bbox'].get_area()
615
+ for eb in existing_boxes
616
+ )
617
+ if not is_covered:
618
+ elements.append(link)
619
+
620
+ if image_pieces:
621
+ for piece in image_pieces:
622
+ if isinstance(piece, dict) and 'bbox' in piece:
623
+ elements.append(piece)
624
+ elif isinstance(piece, tuple) and len(piece) == 2:
625
+ y0_pos, img_txt = piece
626
+ width = page.rect.width if page else 600
627
+ img_bbox = fitz.Rect(0, y0_pos, width, y0_pos + 10)
628
+ elements.append({'bbox': img_bbox, 'text': img_txt, 'raw_text': img_txt, 'type': 'image'})
629
+
630
+ ordered = order_layout_elements(elements)
631
+ decorated = apply_box_decorations(ordered, drawing_boxes)
632
+ return "\n\n".join(e['text'] for e in decorated if e.get('text', '').strip()).strip()
633
+
634
+
635
+ def process_pdf_file(filename, original_filename=None, process_images=None, model=None):
636
+ display_name = os.path.basename(original_filename) if original_filename else os.path.basename(filename)
637
+ should_process_images = bool(process_images)
638
+
639
+ if not should_process_images:
640
+ chunks = []
641
+ with fitz.open(filename) as doc:
642
+ for i in range(len(doc)):
643
+ page = doc.load_page(i)
644
+ tables = extract_tables_from_pdf_page([], page, i + 1)
645
+ links = extract_links_from_page(page)
646
+ formatted_body = format_page_elements(page, tables, links)
647
+ header = f"[Page {i + 1} | File: {display_name}]"
648
+ page_content = header + "\n\n" + formatted_body if formatted_body else header
649
+ if page_content:
650
+ chunks.append({
651
+ "content": page_content,
652
+ "chunkIndex": i + 1,
653
+ "filename": display_name
654
+ })
655
+ else:
656
+ logger.debug(f"No content extracted for page {i+1}")
657
+ return chunks
658
+
659
+ # Multithreaded Vision Pipeline (when process_images is True)
660
+ all_chunks = []
661
+ lock = threading.Lock()
662
+ seen_image_hashes = {}
663
+ image_data_store = {}
664
+ image_page_map = defaultdict(list)
665
+
666
+ precomputed_tables = []
667
+ with fitz.open(filename) as doc:
668
+ for i in range(len(doc)):
669
+ page = doc.load_page(i)
670
+ tables = extract_tables_from_pdf_page([], page, i + 1)
671
+ precomputed_tables.append(tables)
672
+
673
+ def process_page(i):
674
+ with fitz.open(filename) as doc:
675
+ page = doc.load_page(i)
676
+ logger.debug(f"Processing Page {i + 1}")
677
+
678
+ image_processing_texts, need_full_page_image_processing = process_image(
679
+ seen_image_hashes, image_data_store, image_page_map, page, i, doc, lock=lock, model=model
680
+ )
681
+
682
+ if need_full_page_image_processing:
683
+ header = f"[Page {i + 1} | File: {display_name}]"
684
+ page_content = header + "\n\n" + "\n\n".join(piece[1] for piece in image_processing_texts).strip()
685
+ if page_content:
686
+ return {
687
+ "content": page_content,
688
+ "chunkIndex": i + 1,
689
+ "filename": display_name
690
+ }
691
+ else:
692
+ logger.debug(f"No content extracted for page {i+1}")
693
+ return None
694
+
695
+ tables = precomputed_tables[i]
696
+ links = extract_links_from_page(page)
697
+ formatted_body = format_page_elements(page, tables, links, image_pieces=image_processing_texts)
698
+ header = f"[Page {i + 1} | File: {display_name}]"
699
+ page_content = header + "\n\n" + formatted_body if formatted_body else header
700
+ if page_content:
701
+ return {
702
+ "content": page_content,
703
+ "chunkIndex": i + 1,
704
+ "filename": display_name
705
+ }
706
+ else:
707
+ logger.debug(f"No content extracted for page {i+1}")
708
+ return None
709
+
710
+ workers = int(os.environ.get("MAX_WORKERS", 3))
711
+ with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
712
+ results = list(executor.map(process_page, range(len(precomputed_tables))))
713
+
714
+ all_chunks = [chunk for chunk in results if chunk]
715
+ return all_chunks