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,824 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import logging
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from docx import Document
|
|
6
|
+
from docx.text.paragraph import Paragraph
|
|
7
|
+
from docx.table import Table
|
|
8
|
+
from docx.oxml.ns import qn
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("super_document_processor.docx")
|
|
11
|
+
try:
|
|
12
|
+
from super_document_processor.utils.docx_image_utils import quick_image_check, extract_images_from_docx
|
|
13
|
+
from super_document_processor.utils.docx_xml_parser import print_list_item, extract_footnotes, extract_endnotes
|
|
14
|
+
from super_document_processor.utils.vlm import generate_image_description
|
|
15
|
+
from super_document_processor.utils.chunker import collector
|
|
16
|
+
except ImportError:
|
|
17
|
+
from utils.docx_image_utils import quick_image_check, extract_images_from_docx
|
|
18
|
+
from utils.docx_xml_parser import print_list_item, extract_footnotes, extract_endnotes
|
|
19
|
+
from utils.vlm import generate_image_description
|
|
20
|
+
from utils.chunker import collector
|
|
21
|
+
|
|
22
|
+
class DocxElement:
|
|
23
|
+
"""Base class to mimic unstructured element structure"""
|
|
24
|
+
def __init__(self, text="", element_type="", metadata=None):
|
|
25
|
+
self.text = text
|
|
26
|
+
self.element_type = element_type
|
|
27
|
+
self.metadata = metadata or {}
|
|
28
|
+
|
|
29
|
+
class DocxMetadata:
|
|
30
|
+
"""Metadata container to mimic unstructured metadata"""
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.emphasized_text_contents = []
|
|
33
|
+
self.emphasized_text_tags = []
|
|
34
|
+
self.links = []
|
|
35
|
+
self.text_as_html = None
|
|
36
|
+
self.category_depth = 0
|
|
37
|
+
self.header_footer_type = 'primary'
|
|
38
|
+
self.list_level = 0
|
|
39
|
+
self.is_ordered = False
|
|
40
|
+
|
|
41
|
+
def extract_text_formatting(paragraph):
|
|
42
|
+
"""Extract formatting information and inline hyperlinks in true child order"""
|
|
43
|
+
emphasized_contents = []
|
|
44
|
+
emphasized_tags = []
|
|
45
|
+
links = []
|
|
46
|
+
|
|
47
|
+
# Check for hyperlinks across child elements
|
|
48
|
+
for child in paragraph._element:
|
|
49
|
+
tag = child.tag.split('}')[-1]
|
|
50
|
+
if tag == 'hyperlink':
|
|
51
|
+
r_id = child.get(qn('r:id'))
|
|
52
|
+
if r_id:
|
|
53
|
+
try:
|
|
54
|
+
rel = paragraph.part.rels.get(r_id)
|
|
55
|
+
url = rel.target_ref if rel else None
|
|
56
|
+
link_txt = ''.join([t_el.text for t_el in child.findall('.//' + qn('w:t')) if t_el.text]).strip()
|
|
57
|
+
if link_txt and url:
|
|
58
|
+
links.append({'text': link_txt, 'url': url})
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
for run in paragraph.runs:
|
|
63
|
+
txt = run.text.strip()
|
|
64
|
+
if txt:
|
|
65
|
+
tags = []
|
|
66
|
+
if run.bold:
|
|
67
|
+
tags.append('b')
|
|
68
|
+
if run.italic:
|
|
69
|
+
tags.append('i')
|
|
70
|
+
if run.underline:
|
|
71
|
+
tags.append('u')
|
|
72
|
+
for tag in tags:
|
|
73
|
+
emphasized_contents.append(txt)
|
|
74
|
+
emphasized_tags.append(tag)
|
|
75
|
+
|
|
76
|
+
return emphasized_contents, emphasized_tags, links
|
|
77
|
+
|
|
78
|
+
def get_heading_level(paragraph):
|
|
79
|
+
"""Determine heading level from paragraph style"""
|
|
80
|
+
style_name = paragraph.style.name.lower()
|
|
81
|
+
if 'heading' in style_name:
|
|
82
|
+
match = re.search(r'heading\s*(\d+)', style_name)
|
|
83
|
+
if match:
|
|
84
|
+
return int(match.group(1))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
def build_numbering_map(doc):
|
|
88
|
+
"""
|
|
89
|
+
Parse numbering.xml in docx part to map numId -> { ilvl: numFmt }
|
|
90
|
+
e.g. {'1': {'0': 'bullet'}, '5': {'0': 'decimal'}}
|
|
91
|
+
"""
|
|
92
|
+
numbering_map = {}
|
|
93
|
+
try:
|
|
94
|
+
num_part = getattr(doc.part, 'numbering_part', None)
|
|
95
|
+
if num_part is None:
|
|
96
|
+
return numbering_map
|
|
97
|
+
num_xml = num_part._element
|
|
98
|
+
|
|
99
|
+
num_to_abstract = {}
|
|
100
|
+
for num in num_xml.findall(qn('w:num')):
|
|
101
|
+
nid = num.get(qn('w:numId'))
|
|
102
|
+
abId_elem = num.find(qn('w:abstractNumId'))
|
|
103
|
+
if abId_elem is not None:
|
|
104
|
+
num_to_abstract[nid] = abId_elem.get(qn('w:val'))
|
|
105
|
+
|
|
106
|
+
abstract_formats = {}
|
|
107
|
+
for ab in num_xml.findall(qn('w:abstractNum')):
|
|
108
|
+
abId = ab.get(qn('w:abstractNumId'))
|
|
109
|
+
fmts = {}
|
|
110
|
+
for lvl in ab.findall(qn('w:lvl')):
|
|
111
|
+
ilvl = lvl.get(qn('w:ilvl'))
|
|
112
|
+
numFmt = lvl.find(qn('w:numFmt'))
|
|
113
|
+
if numFmt is not None:
|
|
114
|
+
fmts[ilvl] = numFmt.get(qn('w:val'))
|
|
115
|
+
abstract_formats[abId] = fmts
|
|
116
|
+
|
|
117
|
+
for nid, abId in num_to_abstract.items():
|
|
118
|
+
numbering_map[nid] = abstract_formats.get(abId, {})
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.debug(f"Could not build numbering map: {e}")
|
|
121
|
+
return numbering_map
|
|
122
|
+
|
|
123
|
+
def get_list_level(paragraph):
|
|
124
|
+
"""Get list indentation level from XML numPr or style name"""
|
|
125
|
+
# 1. Check paragraph-level pPr numPr
|
|
126
|
+
if paragraph._element.pPr is not None:
|
|
127
|
+
numPr = paragraph._element.pPr.find(qn('w:numPr'))
|
|
128
|
+
if numPr is not None:
|
|
129
|
+
ilvl = numPr.find(qn('w:ilvl'))
|
|
130
|
+
if ilvl is not None:
|
|
131
|
+
return int(ilvl.get(qn('w:val'), 0))
|
|
132
|
+
# 2. Check style hierarchy numPr
|
|
133
|
+
cur = paragraph.style
|
|
134
|
+
while cur:
|
|
135
|
+
if cur._element.pPr is not None:
|
|
136
|
+
numPr = cur._element.pPr.find(qn('w:numPr'))
|
|
137
|
+
if numPr is not None:
|
|
138
|
+
ilvl = numPr.find(qn('w:ilvl'))
|
|
139
|
+
if ilvl is not None:
|
|
140
|
+
return int(ilvl.get(qn('w:val'), 0))
|
|
141
|
+
cur = getattr(cur, 'base_style', None)
|
|
142
|
+
# 3. Fallback to style name level (e.g., 'List Bullet 2' -> level 1)
|
|
143
|
+
style_name = paragraph.style.name.lower()
|
|
144
|
+
m = re.search(r'(\d+)$', style_name)
|
|
145
|
+
if m:
|
|
146
|
+
return max(0, int(m.group(1)) - 1)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
def is_list_paragraph(paragraph):
|
|
150
|
+
"""Check if paragraph is part of a list from XML numPr, style name, or bullet/number prefix"""
|
|
151
|
+
# 1. Paragraph numPr
|
|
152
|
+
if paragraph._element.pPr is not None:
|
|
153
|
+
numPr = paragraph._element.pPr.find(qn('w:numPr'))
|
|
154
|
+
if numPr is not None:
|
|
155
|
+
return True
|
|
156
|
+
# 2. Style hierarchy numPr
|
|
157
|
+
cur = paragraph.style
|
|
158
|
+
while cur:
|
|
159
|
+
if cur._element.pPr is not None:
|
|
160
|
+
numPr = cur._element.pPr.find(qn('w:numPr'))
|
|
161
|
+
if numPr is not None:
|
|
162
|
+
return True
|
|
163
|
+
cur = getattr(cur, 'base_style', None)
|
|
164
|
+
# 3. Style name
|
|
165
|
+
style_name = paragraph.style.name.lower()
|
|
166
|
+
if 'list' in style_name or 'bullet' in style_name:
|
|
167
|
+
return True
|
|
168
|
+
# 4. Text prefix
|
|
169
|
+
txt = paragraph.text.strip()
|
|
170
|
+
if re.match(r'^(?:[•*➢➤➔➜►▶→⇒»›✓✔★✦✧\u25cf\u25a0\u25a1\u25aa\u25ab\u25cb\u25e6\u25c6\u25c7\u25c8\u2756\u2219\uf0b7\u27a2\uf0d8\uf0fc\uf06f\uf0a7\uf0a8\uf075]|[-–—]\s+|o\s+|\d{1,3}[.)]\s+)', txt):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def is_ordered_list(paragraph, numbering_map=None):
|
|
175
|
+
"""Check if a list paragraph is ordered (numbered) rather than unordered (bulleted)"""
|
|
176
|
+
# 1. Resolve numPr from paragraph or style hierarchy
|
|
177
|
+
numPr = None
|
|
178
|
+
if paragraph._element.pPr is not None:
|
|
179
|
+
numPr = paragraph._element.pPr.find(qn('w:numPr'))
|
|
180
|
+
if numPr is None:
|
|
181
|
+
cur = paragraph.style
|
|
182
|
+
while cur:
|
|
183
|
+
if cur._element.pPr is not None:
|
|
184
|
+
numPr = cur._element.pPr.find(qn('w:numPr'))
|
|
185
|
+
if numPr is not None:
|
|
186
|
+
break
|
|
187
|
+
cur = getattr(cur, 'base_style', None)
|
|
188
|
+
|
|
189
|
+
# 2. Check numbering format from numbering.xml
|
|
190
|
+
if numPr is not None and numbering_map:
|
|
191
|
+
numId_elem = numPr.find(qn('w:numId'))
|
|
192
|
+
ilvl_elem = numPr.find(qn('w:ilvl'))
|
|
193
|
+
if numId_elem is not None:
|
|
194
|
+
nid = numId_elem.get(qn('w:val'))
|
|
195
|
+
ilvl = ilvl_elem.get(qn('w:val'), '0') if ilvl_elem is not None else '0'
|
|
196
|
+
fmt = numbering_map.get(nid, {}).get(ilvl) or numbering_map.get(nid, {}).get('0')
|
|
197
|
+
if fmt:
|
|
198
|
+
return fmt != 'bullet'
|
|
199
|
+
|
|
200
|
+
# 3. Check style name
|
|
201
|
+
style_name = paragraph.style.name.lower()
|
|
202
|
+
if 'number' in style_name or 'decimal' in style_name or 'count' in style_name:
|
|
203
|
+
return True
|
|
204
|
+
if 'bullet' in style_name:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
# 4. Check paragraph text prefix
|
|
208
|
+
txt = paragraph.text.strip()
|
|
209
|
+
if re.match(r'^(?:\d{1,3}[.)]|[a-zA-Z][.)]|[ivxlcdmIVXLCDM]+[.)])\s+', txt):
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def extract_table_html(table):
|
|
214
|
+
"""
|
|
215
|
+
Extract table from the document with full gridSpan (horizontal)
|
|
216
|
+
and vMerge (vertical) support, formatting cleanly as an HTML table.
|
|
217
|
+
"""
|
|
218
|
+
tbl_elem = table._element
|
|
219
|
+
grid = tbl_elem.find(qn('w:tblGrid'))
|
|
220
|
+
if grid is not None:
|
|
221
|
+
total_cols = len(grid.findall(qn('w:gridCol')))
|
|
222
|
+
else:
|
|
223
|
+
total_cols = max(
|
|
224
|
+
sum(
|
|
225
|
+
int(tc.tcPr.find(qn('w:gridSpan')).get(qn('w:val')))
|
|
226
|
+
if tc.tcPr is not None and tc.tcPr.find(qn('w:gridSpan')) is not None
|
|
227
|
+
else 1
|
|
228
|
+
for tc in tr.tc_lst
|
|
229
|
+
)
|
|
230
|
+
for tr in tbl_elem.tr_lst
|
|
231
|
+
) if tbl_elem.tr_lst else 1
|
|
232
|
+
|
|
233
|
+
rows = []
|
|
234
|
+
vmerge_map = {}
|
|
235
|
+
|
|
236
|
+
for tr in tbl_elem.tr_lst:
|
|
237
|
+
row_cells = [None] * total_cols
|
|
238
|
+
col_idx = 0
|
|
239
|
+
for tc in tr.tc_lst:
|
|
240
|
+
while col_idx < total_cols and row_cells[col_idx] is not None:
|
|
241
|
+
col_idx += 1
|
|
242
|
+
if col_idx >= total_cols:
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
tcPr = tc.tcPr
|
|
246
|
+
gridSpan = tcPr.find(qn('w:gridSpan')) if tcPr is not None else None
|
|
247
|
+
span_val = int(gridSpan.get(qn('w:val'))) if gridSpan is not None else 1
|
|
248
|
+
|
|
249
|
+
vMerge = tcPr.find(qn('w:vMerge')) if tcPr is not None else None
|
|
250
|
+
v_val = vMerge.get(qn('w:val'), 'continue') if vMerge is not None else None
|
|
251
|
+
|
|
252
|
+
p_texts = []
|
|
253
|
+
for p in tc.iterchildren():
|
|
254
|
+
if p.tag.endswith('}p'):
|
|
255
|
+
txt = ''.join(t.text for t in p.iter() if t.tag.endswith('}t') and t.text).strip()
|
|
256
|
+
if txt:
|
|
257
|
+
p_texts.append(txt)
|
|
258
|
+
cell_text = '<br>'.join(p_texts).strip()
|
|
259
|
+
|
|
260
|
+
if vMerge is not None:
|
|
261
|
+
if v_val == 'restart':
|
|
262
|
+
vmerge_map[col_idx] = cell_text
|
|
263
|
+
else:
|
|
264
|
+
cell_text = ''
|
|
265
|
+
else:
|
|
266
|
+
if col_idx in vmerge_map:
|
|
267
|
+
del vmerge_map[col_idx]
|
|
268
|
+
|
|
269
|
+
for s in range(span_val):
|
|
270
|
+
if col_idx + s < total_cols:
|
|
271
|
+
row_cells[col_idx + s] = cell_text if s == 0 else ''
|
|
272
|
+
col_idx += span_val
|
|
273
|
+
|
|
274
|
+
rows.append(row_cells)
|
|
275
|
+
|
|
276
|
+
html_parts = ['<table>']
|
|
277
|
+
for i, r in enumerate(rows):
|
|
278
|
+
html_parts.append(' <tr>')
|
|
279
|
+
tag = 'th' if i == 0 else 'td'
|
|
280
|
+
for cell in r:
|
|
281
|
+
val = cell if cell is not None else ''
|
|
282
|
+
html_parts.append(f' <{tag}>{val}</{tag}>')
|
|
283
|
+
html_parts.append(' </tr>')
|
|
284
|
+
html_parts.append('</table>')
|
|
285
|
+
return '\n'.join(html_parts)
|
|
286
|
+
|
|
287
|
+
def analyze_document_structure(doc):
|
|
288
|
+
"""
|
|
289
|
+
Analyze document structure to identify where specific images belong.
|
|
290
|
+
Returns a mapping of block indices to their specific image r:id references.
|
|
291
|
+
"""
|
|
292
|
+
block_image_map = {}
|
|
293
|
+
|
|
294
|
+
for idx, block in enumerate(doc.element.body):
|
|
295
|
+
# Look for specific image references in this block
|
|
296
|
+
image_refs = []
|
|
297
|
+
|
|
298
|
+
# Check for drawing elements with blip r:embed attributes
|
|
299
|
+
for drawing in block.iter():
|
|
300
|
+
local_tag = drawing.tag.split("}")[-1]
|
|
301
|
+
if local_tag == "blip":
|
|
302
|
+
r_embed = drawing.get(qn('r:embed'))
|
|
303
|
+
if r_embed:
|
|
304
|
+
image_refs.append(r_embed)
|
|
305
|
+
elif local_tag == "imagedata":
|
|
306
|
+
r_id = drawing.get(qn('r:id'))
|
|
307
|
+
if r_id:
|
|
308
|
+
image_refs.append(r_id)
|
|
309
|
+
|
|
310
|
+
# If this block contains specific image references, record them
|
|
311
|
+
if image_refs:
|
|
312
|
+
block_image_map[idx] = image_refs
|
|
313
|
+
|
|
314
|
+
return block_image_map
|
|
315
|
+
|
|
316
|
+
def create_image_elements_for_block(block_image_refs, extracted_images, image_desc_cache):
|
|
317
|
+
"""
|
|
318
|
+
Create image elements for specific images found in a block.
|
|
319
|
+
Returns a list of DocxElement objects for images, matched by r:id.
|
|
320
|
+
"""
|
|
321
|
+
image_elements = []
|
|
322
|
+
|
|
323
|
+
# Match images by their r:id references
|
|
324
|
+
for r_id in block_image_refs:
|
|
325
|
+
# Find the corresponding image in extracted_images
|
|
326
|
+
matching_image = None
|
|
327
|
+
for img_data in extracted_images:
|
|
328
|
+
if img_data.get('r_id') == r_id:
|
|
329
|
+
matching_image = img_data
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
if matching_image:
|
|
333
|
+
metadata = DocxMetadata()
|
|
334
|
+
elem = DocxElement("", 'Image', metadata)
|
|
335
|
+
|
|
336
|
+
# Set image attributes
|
|
337
|
+
elem.image_alt_text = matching_image.get('alt_text', '')
|
|
338
|
+
elem.image_title = matching_image.get('title', '')
|
|
339
|
+
elem.image_name = matching_image.get('name', '')
|
|
340
|
+
elem.image_r_id = matching_image.get('r_id', '')
|
|
341
|
+
elem.image_data = matching_image.get('data')
|
|
342
|
+
elem.image_hash = matching_image.get('hash')
|
|
343
|
+
|
|
344
|
+
image_elements.append(elem)
|
|
345
|
+
|
|
346
|
+
return image_elements
|
|
347
|
+
|
|
348
|
+
def group_consecutive_lists(elements):
|
|
349
|
+
"""Group consecutive list items together"""
|
|
350
|
+
grouped = []
|
|
351
|
+
current_list_group = []
|
|
352
|
+
|
|
353
|
+
for element in elements:
|
|
354
|
+
if element.element_type == 'ListItem':
|
|
355
|
+
current_list_group.append(element)
|
|
356
|
+
else:
|
|
357
|
+
if current_list_group:
|
|
358
|
+
grouped.append(('LIST_GROUP', current_list_group))
|
|
359
|
+
current_list_group = []
|
|
360
|
+
grouped.append(('SINGLE', element))
|
|
361
|
+
|
|
362
|
+
if current_list_group:
|
|
363
|
+
grouped.append(('LIST_GROUP', current_list_group))
|
|
364
|
+
|
|
365
|
+
return grouped
|
|
366
|
+
|
|
367
|
+
def format_text_with_emphasis(text, emphasized_contents, emphasized_tags):
|
|
368
|
+
"""Format text with proper emphasis tags including nested styles."""
|
|
369
|
+
if not emphasized_contents or not emphasized_tags or not text:
|
|
370
|
+
return text
|
|
371
|
+
|
|
372
|
+
# List of (start_idx, end_idx, tag)
|
|
373
|
+
emphasis_spans = []
|
|
374
|
+
|
|
375
|
+
# Map each content to list of tags
|
|
376
|
+
for content, tag in zip(emphasized_contents, emphasized_tags):
|
|
377
|
+
start_idx = text.find(content)
|
|
378
|
+
while start_idx != -1:
|
|
379
|
+
end_idx = start_idx + len(content)
|
|
380
|
+
emphasis_spans.append((start_idx, end_idx, tag))
|
|
381
|
+
start_idx = text.find(content, start_idx + 1)
|
|
382
|
+
emphasis_spans.sort()
|
|
383
|
+
|
|
384
|
+
# Build final HTML by applying tags in order
|
|
385
|
+
result = []
|
|
386
|
+
last_idx = 0
|
|
387
|
+
|
|
388
|
+
i = 0
|
|
389
|
+
while i < len(emphasis_spans):
|
|
390
|
+
start, end, tag = emphasis_spans[i]
|
|
391
|
+
|
|
392
|
+
result.append(text[last_idx:start])
|
|
393
|
+
|
|
394
|
+
tags_to_apply = set()
|
|
395
|
+
while i < len(emphasis_spans) and emphasis_spans[i][0] == start and emphasis_spans[i][1] == end:
|
|
396
|
+
tags_to_apply.add(emphasis_spans[i][2])
|
|
397
|
+
i += 1
|
|
398
|
+
|
|
399
|
+
# Apply opening tags
|
|
400
|
+
for tag in sorted(tags_to_apply):
|
|
401
|
+
result.append(f"<{tag}>")
|
|
402
|
+
|
|
403
|
+
result.append(text[start:end])
|
|
404
|
+
|
|
405
|
+
for tag in reversed(sorted(tags_to_apply)):
|
|
406
|
+
result.append(f"</{tag}>")
|
|
407
|
+
|
|
408
|
+
last_idx = end
|
|
409
|
+
|
|
410
|
+
result.append(text[last_idx:])
|
|
411
|
+
return ''.join(result)
|
|
412
|
+
|
|
413
|
+
def process_image_element(element, image_desc_cache, image_counter):
|
|
414
|
+
"""Process a single image element and add it to the collector"""
|
|
415
|
+
collector.add(f"<-- IMAGE {image_counter} START -->")
|
|
416
|
+
|
|
417
|
+
# Extract detailed image metadata
|
|
418
|
+
alt_text = getattr(element, 'image_alt_text', None)
|
|
419
|
+
title = getattr(element, 'image_title', None)
|
|
420
|
+
name = getattr(element, 'image_name', None)
|
|
421
|
+
r_id = getattr(element, 'image_r_id', None)
|
|
422
|
+
image_data = getattr(element, 'image_data', None)
|
|
423
|
+
img_hash = getattr(element, 'image_hash', None)
|
|
424
|
+
|
|
425
|
+
# Generate or retrieve cached description
|
|
426
|
+
if image_data and img_hash:
|
|
427
|
+
if img_hash in image_desc_cache:
|
|
428
|
+
generated_desc = image_desc_cache[img_hash]
|
|
429
|
+
else:
|
|
430
|
+
try:
|
|
431
|
+
generated_desc = generate_image_description(image_data)
|
|
432
|
+
image_desc_cache[img_hash] = generated_desc
|
|
433
|
+
except Exception as e:
|
|
434
|
+
generated_desc = f"[Error generating description: {e}]"
|
|
435
|
+
image_desc_cache[img_hash] = generated_desc
|
|
436
|
+
collector.add(f"<figcaption>{generated_desc}</figcaption>")
|
|
437
|
+
|
|
438
|
+
# Build image attributes
|
|
439
|
+
img_attrs = []
|
|
440
|
+
if alt_text:
|
|
441
|
+
img_attrs.append(f"alt='{alt_text}'")
|
|
442
|
+
if title:
|
|
443
|
+
img_attrs.append(f"title='{title}'")
|
|
444
|
+
if name:
|
|
445
|
+
img_attrs.append(f"data-name='{name}'")
|
|
446
|
+
if r_id:
|
|
447
|
+
img_attrs.append(f"data-r-id='{r_id}'")
|
|
448
|
+
attrs_str = " " + " ".join(img_attrs) if img_attrs else ""
|
|
449
|
+
collector.add(f"<img{attrs_str} />")
|
|
450
|
+
collector.add(f"<-- IMAGE {image_counter} END -->")
|
|
451
|
+
|
|
452
|
+
def process_docx_file(filename, original_filename=None, process_images=None, model=None):
|
|
453
|
+
"""
|
|
454
|
+
Extract and display DOCX content in natural document flow with properly positioned images, headers, and footers
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
doc = Document(filename)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.error(f"Error reading DOCX '{filename}': {e}")
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
display_name = os.path.basename(original_filename) if original_filename else os.path.basename(filename)
|
|
463
|
+
should_process_images = bool(process_images)
|
|
464
|
+
has_images = quick_image_check(filename) if should_process_images else False
|
|
465
|
+
extracted_images = []
|
|
466
|
+
if has_images:
|
|
467
|
+
extracted_images = extract_images_from_docx(filename)
|
|
468
|
+
block_image_map = analyze_document_structure(doc)
|
|
469
|
+
else:
|
|
470
|
+
block_image_map = {}
|
|
471
|
+
|
|
472
|
+
elements = []
|
|
473
|
+
|
|
474
|
+
# --- Capture headers and footers for each section ---
|
|
475
|
+
for section in doc.sections:
|
|
476
|
+
# Headers
|
|
477
|
+
for header_type in ['header', 'first_page_header', 'even_page_header']:
|
|
478
|
+
header = getattr(section, header_type, None)
|
|
479
|
+
if header and header.is_linked_to_previous is False:
|
|
480
|
+
for para in header.paragraphs:
|
|
481
|
+
text = para.text.strip()
|
|
482
|
+
if text:
|
|
483
|
+
metadata = DocxMetadata()
|
|
484
|
+
metadata.header_footer_type = header_type
|
|
485
|
+
metadata.emphasized_text_contents, metadata.emphasized_text_tags, metadata.links = extract_text_formatting(para)
|
|
486
|
+
elements.append(DocxElement(text, 'Header', metadata))
|
|
487
|
+
# Footers
|
|
488
|
+
for footer_type in ['footer', 'first_page_footer', 'even_page_footer']:
|
|
489
|
+
footer = getattr(section, footer_type, None)
|
|
490
|
+
if footer and footer.is_linked_to_previous is False:
|
|
491
|
+
for para in footer.paragraphs:
|
|
492
|
+
text = para.text.strip()
|
|
493
|
+
if text:
|
|
494
|
+
metadata = DocxMetadata()
|
|
495
|
+
metadata.header_footer_type = footer_type
|
|
496
|
+
metadata.emphasized_text_contents, metadata.emphasized_text_tags, metadata.links = extract_text_formatting(para)
|
|
497
|
+
elements.append(DocxElement(text, 'Footer', metadata))
|
|
498
|
+
# --- End header/footer capture ---
|
|
499
|
+
|
|
500
|
+
# Initialize image description cache
|
|
501
|
+
image_desc_cache = {}
|
|
502
|
+
if extracted_images:
|
|
503
|
+
# Pre-generate descriptions for all images in parallel
|
|
504
|
+
import concurrent.futures
|
|
505
|
+
def desc_task(img):
|
|
506
|
+
try:
|
|
507
|
+
return img['hash'], generate_image_description(img['data'], model=model)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
return img['hash'], f"[Error generating description: {e}]"
|
|
510
|
+
|
|
511
|
+
workers = int(os.environ.get("MAX_WORKERS", 3))
|
|
512
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
513
|
+
results = list(executor.map(desc_task, extracted_images))
|
|
514
|
+
|
|
515
|
+
for h, desc in results:
|
|
516
|
+
image_desc_cache[h] = desc
|
|
517
|
+
|
|
518
|
+
# Build numbering map for resolving ordered vs unordered lists
|
|
519
|
+
numbering_map = build_numbering_map(doc)
|
|
520
|
+
|
|
521
|
+
# Process each block in the document
|
|
522
|
+
for block_idx, element in enumerate(doc.element.body):
|
|
523
|
+
tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
524
|
+
|
|
525
|
+
# First, add any images that belong to this block
|
|
526
|
+
if block_idx in block_image_map:
|
|
527
|
+
block_images = block_image_map[block_idx]
|
|
528
|
+
image_elements = create_image_elements_for_block(block_images, extracted_images, image_desc_cache)
|
|
529
|
+
elements.extend(image_elements)
|
|
530
|
+
|
|
531
|
+
# Helper to check if this block has a page break or section break
|
|
532
|
+
has_break = False
|
|
533
|
+
if block_idx < len(doc.element.body) - 1:
|
|
534
|
+
for br in element.iter(qn('w:br')):
|
|
535
|
+
if br.get(qn('w:type')) == 'page':
|
|
536
|
+
has_break = True
|
|
537
|
+
break
|
|
538
|
+
if not has_break:
|
|
539
|
+
for lr in element.iter(qn('w:lastRenderedPageBreak')):
|
|
540
|
+
has_break = True
|
|
541
|
+
break
|
|
542
|
+
if not has_break:
|
|
543
|
+
pPr = element.find(qn('w:pPr'))
|
|
544
|
+
if pPr is not None and pPr.find(qn('w:sectPr')) is not None:
|
|
545
|
+
has_break = True
|
|
546
|
+
if not has_break and tag == 'sectPr':
|
|
547
|
+
has_break = True
|
|
548
|
+
|
|
549
|
+
if tag == 'p': # Paragraph
|
|
550
|
+
try:
|
|
551
|
+
para = Paragraph(element, doc)
|
|
552
|
+
except Exception:
|
|
553
|
+
para = None
|
|
554
|
+
|
|
555
|
+
if para is None:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
text = para.text.strip()
|
|
559
|
+
if not text:
|
|
560
|
+
# Check if paragraph contains floating text boxes (callout boxes / shape text)
|
|
561
|
+
has_txbx = False
|
|
562
|
+
for txbx in element.iter(qn('w:txBxContent')):
|
|
563
|
+
for sub_p in txbx.iter(qn('w:p')):
|
|
564
|
+
sub_text = ''.join(x.text for x in sub_p.iter() if x.tag.endswith('}t') and x.text).strip()
|
|
565
|
+
if sub_text:
|
|
566
|
+
sub_meta = DocxMetadata()
|
|
567
|
+
elements.append(DocxElement(sub_text, 'NarrativeText', sub_meta))
|
|
568
|
+
has_txbx = True
|
|
569
|
+
if has_txbx:
|
|
570
|
+
if has_break:
|
|
571
|
+
elements.append(DocxElement("", 'PageBreak', DocxMetadata()))
|
|
572
|
+
has_break = False
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
# Check if paragraph contains an equation or floating drawing
|
|
576
|
+
maths = list(element.iter('{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath'))
|
|
577
|
+
if maths:
|
|
578
|
+
m_parts = []
|
|
579
|
+
for m in maths:
|
|
580
|
+
m_txt = ''.join(x.text for x in m.iter() if x.tag.endswith('}t') and x.text).strip()
|
|
581
|
+
if m_txt:
|
|
582
|
+
m_parts.append(m_txt)
|
|
583
|
+
if m_parts:
|
|
584
|
+
text = ' '.join(m_parts)
|
|
585
|
+
metadata = DocxMetadata()
|
|
586
|
+
elements.append(DocxElement(text, 'Formula', metadata))
|
|
587
|
+
if has_break:
|
|
588
|
+
elements.append(DocxElement("", 'PageBreak', DocxMetadata()))
|
|
589
|
+
has_break = False
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
# Create metadata
|
|
593
|
+
metadata = DocxMetadata()
|
|
594
|
+
metadata.emphasized_text_contents, metadata.emphasized_text_tags, metadata.links = extract_text_formatting(para)
|
|
595
|
+
|
|
596
|
+
# Determine element type
|
|
597
|
+
heading_level = get_heading_level(para)
|
|
598
|
+
if heading_level > 0:
|
|
599
|
+
metadata.category_depth = heading_level - 1
|
|
600
|
+
elem = DocxElement(text, 'Title', metadata)
|
|
601
|
+
elif is_list_paragraph(para):
|
|
602
|
+
metadata.list_level = get_list_level(para)
|
|
603
|
+
metadata.is_ordered = is_ordered_list(para, numbering_map)
|
|
604
|
+
elem = DocxElement(text, 'ListItem', metadata)
|
|
605
|
+
else:
|
|
606
|
+
elem = DocxElement(text, 'NarrativeText', metadata)
|
|
607
|
+
|
|
608
|
+
elements.append(elem)
|
|
609
|
+
|
|
610
|
+
# Also check for floating text boxes nested alongside text
|
|
611
|
+
for txbx in element.iter(qn('w:txBxContent')):
|
|
612
|
+
for sub_p in txbx.iter(qn('w:p')):
|
|
613
|
+
sub_text = ''.join(x.text for x in sub_p.iter() if x.tag.endswith('}t') and x.text).strip()
|
|
614
|
+
if sub_text:
|
|
615
|
+
sub_meta = DocxMetadata()
|
|
616
|
+
elements.append(DocxElement(sub_text, 'NarrativeText', sub_meta))
|
|
617
|
+
|
|
618
|
+
elif tag == 'tbl': # Table
|
|
619
|
+
try:
|
|
620
|
+
table = Table(element, doc)
|
|
621
|
+
except Exception:
|
|
622
|
+
table = None
|
|
623
|
+
|
|
624
|
+
if table is None:
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
metadata = DocxMetadata()
|
|
628
|
+
table_html = extract_table_html(table)
|
|
629
|
+
metadata.text_as_html = table_html
|
|
630
|
+
elem = DocxElement(table_html, 'Table', metadata)
|
|
631
|
+
elements.append(elem)
|
|
632
|
+
|
|
633
|
+
if has_break:
|
|
634
|
+
elements.append(DocxElement("", 'PageBreak', DocxMetadata()))
|
|
635
|
+
|
|
636
|
+
stats = Counter()
|
|
637
|
+
image_counter = 0
|
|
638
|
+
page_idx = 1
|
|
639
|
+
collector.add(f"[Page 1 | File: {display_name}]\n")
|
|
640
|
+
|
|
641
|
+
# Group consecutive list items
|
|
642
|
+
grouped_elements = group_consecutive_lists(elements)
|
|
643
|
+
|
|
644
|
+
# Process each element/group in document flow order
|
|
645
|
+
for group_type, content in grouped_elements:
|
|
646
|
+
if group_type == 'LIST_GROUP':
|
|
647
|
+
# Handle list group
|
|
648
|
+
collector.add("=== List ===")
|
|
649
|
+
level_counters = {}
|
|
650
|
+
for list_element in content:
|
|
651
|
+
element_type = list_element.element_type
|
|
652
|
+
element_text = list_element.text.strip()
|
|
653
|
+
metadata = list_element.metadata
|
|
654
|
+
|
|
655
|
+
stats[element_type] += 1
|
|
656
|
+
|
|
657
|
+
# Get list level and ordering flag
|
|
658
|
+
list_level = getattr(metadata, 'list_level', 0)
|
|
659
|
+
is_ordered = getattr(metadata, 'is_ordered', False)
|
|
660
|
+
|
|
661
|
+
# Determine bullet or numbered marker
|
|
662
|
+
if is_ordered:
|
|
663
|
+
cnt = level_counters.get(list_level, 0) + 1
|
|
664
|
+
level_counters[list_level] = cnt
|
|
665
|
+
# Reset any deeper nested levels
|
|
666
|
+
for k in list(level_counters.keys()):
|
|
667
|
+
if k > list_level:
|
|
668
|
+
del level_counters[k]
|
|
669
|
+
marker = f"{cnt}."
|
|
670
|
+
else:
|
|
671
|
+
level_counters.clear()
|
|
672
|
+
marker = "-"
|
|
673
|
+
|
|
674
|
+
# Get emphasized content for list items
|
|
675
|
+
emphasized_contents = getattr(metadata, 'emphasized_text_contents', [])
|
|
676
|
+
emphasized_tags = getattr(metadata, 'emphasized_text_tags', [])
|
|
677
|
+
|
|
678
|
+
# Format text with emphasis
|
|
679
|
+
formatted_text = element_text
|
|
680
|
+
if emphasized_contents and emphasized_tags:
|
|
681
|
+
formatted_text = format_text_with_emphasis(formatted_text, emphasized_contents, emphasized_tags)
|
|
682
|
+
|
|
683
|
+
# Check for links in list items
|
|
684
|
+
links = getattr(metadata, 'links', [])
|
|
685
|
+
if links:
|
|
686
|
+
for link in links:
|
|
687
|
+
link_text = link.get('text', '')
|
|
688
|
+
link_url = link.get('url', '')
|
|
689
|
+
if link_text and link_url:
|
|
690
|
+
formatted_text = formatted_text.replace(link_text, f"<a href='{link_url}'>{link_text}</a>")
|
|
691
|
+
|
|
692
|
+
# Print the list item with proper indentation and marker
|
|
693
|
+
print_list_item(formatted_text, list_level, marker=marker)
|
|
694
|
+
|
|
695
|
+
else:
|
|
696
|
+
element = content
|
|
697
|
+
|
|
698
|
+
element_type = element.element_type
|
|
699
|
+
element_text = element.text.strip()
|
|
700
|
+
metadata = element.metadata
|
|
701
|
+
|
|
702
|
+
stats[element_type] += 1
|
|
703
|
+
|
|
704
|
+
# Skip empty elements (except images and page breaks)
|
|
705
|
+
if not element_text and element_type not in ('Image', 'PageBreak'):
|
|
706
|
+
continue
|
|
707
|
+
|
|
708
|
+
if element_type == 'Image':
|
|
709
|
+
image_counter += 1
|
|
710
|
+
process_image_element(element, image_desc_cache, image_counter)
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
if element_type == 'ListItem':
|
|
714
|
+
# This should not happen since we group them, but just in case
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
elif element_type == 'PageBreak':
|
|
718
|
+
page_idx += 1
|
|
719
|
+
collector.add(f"\n[Page {page_idx} | File: {display_name}]\n")
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
elif element_type == 'Title':
|
|
723
|
+
category_depth = getattr(metadata, 'category_depth', 0)
|
|
724
|
+
heading_level = min(category_depth + 1, 6) # HTML h1-h6
|
|
725
|
+
collector.add(f"<h{heading_level}>{element_text}</h{heading_level}>")
|
|
726
|
+
continue
|
|
727
|
+
|
|
728
|
+
elif element_type == 'Header':
|
|
729
|
+
header_type = getattr(metadata, 'header_footer_type', 'primary')
|
|
730
|
+
collector.add(f"<header class='{header_type}'>{element_text}</header>")
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
elif element_type == 'Footer':
|
|
734
|
+
footer_type = getattr(metadata, 'header_footer_type', 'primary')
|
|
735
|
+
collector.add(f"<footer class='{footer_type}'>{element_text}</footer>")
|
|
736
|
+
continue
|
|
737
|
+
|
|
738
|
+
elif element_type == 'Table':
|
|
739
|
+
table_html = getattr(metadata, 'text_as_html', None)
|
|
740
|
+
if table_html:
|
|
741
|
+
collector.add(table_html)
|
|
742
|
+
elif element_text:
|
|
743
|
+
emphasized_contents = getattr(metadata, 'emphasized_text_contents', [])
|
|
744
|
+
emphasized_tags = getattr(metadata, 'emphasized_text_tags', [])
|
|
745
|
+
formatted_text = format_text_with_emphasis(element_text, emphasized_contents, emphasized_tags)
|
|
746
|
+
collector.add(f"<table-text>{formatted_text}</table-text>")
|
|
747
|
+
continue
|
|
748
|
+
|
|
749
|
+
elif element_type in ['NarrativeText', 'Text', 'UncategorizedText']:
|
|
750
|
+
# Get emphasized content for regular text
|
|
751
|
+
emphasized_contents = getattr(metadata, 'emphasized_text_contents', [])
|
|
752
|
+
emphasized_tags = getattr(metadata, 'emphasized_text_tags', [])
|
|
753
|
+
# Check for links
|
|
754
|
+
links = getattr(metadata, 'links', [])
|
|
755
|
+
|
|
756
|
+
formatted_text = element_text
|
|
757
|
+
# Apply emphasis formatting
|
|
758
|
+
if emphasized_contents and emphasized_tags:
|
|
759
|
+
formatted_text = format_text_with_emphasis(formatted_text, emphasized_contents, emphasized_tags)
|
|
760
|
+
# Apply link formatting
|
|
761
|
+
if links:
|
|
762
|
+
for link in links:
|
|
763
|
+
link_text = link.get('text', '')
|
|
764
|
+
link_url = link.get('url', '')
|
|
765
|
+
if link_text and link_url:
|
|
766
|
+
formatted_text = formatted_text.replace(link_text, f"<a href='{link_url}'>{link_text}</a>")
|
|
767
|
+
|
|
768
|
+
collector.add(f"<p>{formatted_text}</p>")
|
|
769
|
+
continue
|
|
770
|
+
|
|
771
|
+
elif element_type == 'Formula':
|
|
772
|
+
collector.add(f"<formula>{element_text}</formula>")
|
|
773
|
+
continue
|
|
774
|
+
|
|
775
|
+
else:
|
|
776
|
+
# Generic element - check for emphasis and links
|
|
777
|
+
emphasized_contents = getattr(metadata, 'emphasized_text_contents', [])
|
|
778
|
+
emphasized_tags = getattr(metadata, 'emphasized_text_tags', [])
|
|
779
|
+
links = getattr(metadata, 'links', [])
|
|
780
|
+
|
|
781
|
+
formatted_text = element_text
|
|
782
|
+
# Apply emphasis formatting
|
|
783
|
+
if emphasized_contents and emphasized_tags:
|
|
784
|
+
formatted_text = format_text_with_emphasis(formatted_text, emphasized_contents, emphasized_tags)
|
|
785
|
+
# Apply link formatting
|
|
786
|
+
if links:
|
|
787
|
+
for link in links:
|
|
788
|
+
link_text = link.get('text', '')
|
|
789
|
+
link_url = link.get('url', '')
|
|
790
|
+
if link_text and link_url:
|
|
791
|
+
formatted_text = formatted_text.replace(link_text, f"<a href='{link_url}'>{link_text}</a>")
|
|
792
|
+
|
|
793
|
+
collector.add(f"<-- {element_type.upper()} START -->")
|
|
794
|
+
collector.add(f"<div class='{element_type.lower()}'>{formatted_text}</div>")
|
|
795
|
+
collector.add(f"<-- {element_type.upper()} END -->")
|
|
796
|
+
|
|
797
|
+
# Extract footnotes and endnotes
|
|
798
|
+
footnotes = extract_footnotes(filename)
|
|
799
|
+
endnotes = extract_endnotes(filename)
|
|
800
|
+
|
|
801
|
+
# Add footnotes
|
|
802
|
+
if footnotes:
|
|
803
|
+
collector.add("\n[Footnotes]")
|
|
804
|
+
for i, note in enumerate(footnotes, 1):
|
|
805
|
+
collector.add(f"[{i}] {note}")
|
|
806
|
+
|
|
807
|
+
# Add endnotes
|
|
808
|
+
if endnotes:
|
|
809
|
+
collector.add("\n[Endnotes]")
|
|
810
|
+
for i, note in enumerate(endnotes, 1):
|
|
811
|
+
collector.add(f"[{i}] {note}")
|
|
812
|
+
|
|
813
|
+
all_chunks = []
|
|
814
|
+
chunked_texts = collector.get_chunked_texts()
|
|
815
|
+
|
|
816
|
+
for i, chunk in enumerate(chunked_texts, 1):
|
|
817
|
+
all_chunks.append({
|
|
818
|
+
"content": f"--- Chunk {i} ---\n{chunk}",
|
|
819
|
+
"chunkIndex": i,
|
|
820
|
+
"filename": display_name
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
collector.clear()
|
|
824
|
+
return all_chunks
|