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