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,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
|