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,135 @@
1
+ import os
2
+ import tempfile
3
+ import logging
4
+ import importlib
5
+ from typing import List, Dict, Any, Optional
6
+ from dotenv import load_dotenv
7
+
8
+ logger = logging.getLogger("super_document_processor")
9
+
10
+ # Load environment variables from local .env file if present
11
+ load_dotenv()
12
+
13
+ # LLM / Vision endpoint configuration via environment variables
14
+ OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
15
+ OPENAI_BASE_URL = os.getenv('OPENAI_BASE_URL')
16
+
17
+ # Flag to process images via VLM (default False; enabled explicitly via CLI/API parameter)
18
+ process_images = False
19
+
20
+ # Server host & port configuration
21
+ PORT = int(os.getenv('PORT', '8000'))
22
+ HOST = "0.0.0.0"
23
+
24
+ PLAIN_TEXT_EXTENSIONS = {
25
+ ".txt", ".md", ".json", ".org", ".rst", ".html", ".xml",
26
+ ".py", ".js", ".jsx", ".tsx", ".java", ".cpp", ".c", ".h",
27
+ ".cs", ".rb", ".go", ".php", ".swift", ".ts",
28
+ ".css", ".sh", ".pl", ".sql", ".yml", ".yaml",
29
+ ".r", ".kt", ".lua", ".vb", ".d", ".scala", ".m", ".vhd",
30
+ ".asm", ".tex", ".sass", ".scss", ".less", ".vue", ".erb",
31
+ ".coffee", ".jade", ".handlebars", ".mustache", ".twig",
32
+ ".hbs", ".liquid", ".pug", ".aspx", ".cshtml"
33
+ }
34
+
35
+ IMAGE_EXTENSIONS = {".jpg", ".png", ".jpeg", ".jfif"}
36
+
37
+ SUPPORTED_EXTENSIONS = {
38
+ ".pdf", ".docx", ".pptx", ".msg"
39
+ } | PLAIN_TEXT_EXTENSIONS | IMAGE_EXTENSIONS
40
+
41
+ # Registry mapping file extensions to (module_name, function_name)
42
+ _PROCESSOR_REGISTRY = {
43
+ ".pdf": ("process_pdf", "process_pdf_file"),
44
+ ".docx": ("process_docx", "process_docx_file"),
45
+ ".pptx": ("process_pptx", "process_pptx_file"),
46
+ ".msg": ("process_msg", "process_msg_file"),
47
+ }
48
+ for _ext in IMAGE_EXTENSIONS:
49
+ _PROCESSOR_REGISTRY[_ext] = ("process_images", "process_image_file")
50
+ for _ext in PLAIN_TEXT_EXTENSIONS:
51
+ _PROCESSOR_REGISTRY[_ext] = ("process_plain_text", "process_plain_file")
52
+
53
+
54
+ def _get_processor(file_type: str):
55
+ """Dynamically import document processors on demand."""
56
+ target = _PROCESSOR_REGISTRY.get(file_type)
57
+ if not target:
58
+ return None
59
+ mod_name, func_name = target
60
+ try:
61
+ mod = importlib.import_module(f"super_document_processor.document_processors.{mod_name}")
62
+ except ImportError:
63
+ mod = importlib.import_module(f"document_processors.{mod_name}")
64
+ return getattr(mod, func_name)
65
+
66
+
67
+ def process_file(
68
+ file_path: str,
69
+ original_filename: Optional[str] = None,
70
+ process_images: Optional[bool] = None,
71
+ model: Optional[str] = None
72
+ ) -> List[Dict[str, Any]]:
73
+ """
74
+ Process any supported document or media file and return a list of extracted chunks.
75
+
76
+ Args:
77
+ file_path: Path to the file to process.
78
+ original_filename: Optional original filename.
79
+ process_images: Optional bool to override default VLM image processing.
80
+ model: Optional vision model name to use for image extraction.
81
+
82
+ Returns:
83
+ List of chunk dictionaries with content, chunkIndex, and filename.
84
+ """
85
+ if not os.path.exists(file_path):
86
+ raise FileNotFoundError(f"File not found: {file_path}")
87
+
88
+ raw_filename = original_filename or os.path.basename(file_path)
89
+ display_filename = os.path.basename(raw_filename)
90
+ file_type = os.path.splitext(display_filename)[1].lower()
91
+
92
+ if file_type not in SUPPORTED_EXTENSIONS:
93
+ raise ValueError(f"Unsupported file type '{file_type}'. Supported extensions: {sorted(SUPPORTED_EXTENSIONS)}")
94
+
95
+ if file_type in IMAGE_EXTENSIONS and not process_images:
96
+ raise ValueError(
97
+ f"Processing image file '{display_filename}' requires image processing to be enabled.\n"
98
+ "Please enable image processing via CLI (--process-images) or API ('process_images': True)."
99
+ )
100
+
101
+ if process_images and not model:
102
+ raise ValueError(
103
+ "Cannot process images: No vision model specified.\n"
104
+ "Please specify a vision-capable model via CLI (--model) or API ('model' parameter)."
105
+ )
106
+
107
+ if model and not process_images:
108
+ raise ValueError(
109
+ "A vision model was specified, but image processing is not enabled.\n"
110
+ "Please enable image processing (via --process-images or process_images=True) or remove the model parameter."
111
+ )
112
+
113
+ processor = _get_processor(file_type)
114
+
115
+ if file_type == ".pdf":
116
+ return processor(file_path, original_filename=display_filename, process_images=process_images, model=model)
117
+
118
+ elif file_type == ".msg":
119
+ return processor(file_path, original_filename=display_filename)
120
+
121
+ elif file_type == ".docx":
122
+ return processor(file_path, original_filename=display_filename, process_images=process_images, model=model)
123
+
124
+ elif file_type == ".pptx":
125
+ return processor(file_path, original_filename=display_filename, process_images=process_images, model=model)
126
+
127
+ elif file_type in PLAIN_TEXT_EXTENSIONS:
128
+ return processor(file_path, original_filename=display_filename)
129
+
130
+ elif file_type in IMAGE_EXTENSIONS:
131
+ chunk = processor(file_path, original_filename=display_filename, process_images=process_images, model=model)
132
+ return [chunk] if chunk else []
133
+
134
+ else:
135
+ raise ValueError(f"Unsupported file type: {file_type}")
@@ -0,0 +1,103 @@
1
+ import io
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ def fix_svg_dimensions(svg_text: str, scale: float = 2.0) -> str:
9
+ """
10
+ Aspose exports SVGs with inch units: width="13.333333in" height="7.5in".
11
+ resvg requires pixel values matching viewBox.
12
+ This replaces width/height in the root <svg> tag with scaled pixel dimensions.
13
+ """
14
+ svg_start = svg_text.find("<svg")
15
+ if svg_start != -1:
16
+ svg_end = svg_text.find(">", svg_start)
17
+ if svg_end != -1:
18
+ header = svg_text[svg_start:svg_end+1]
19
+ vb_match = re.search(r'viewBox=["\']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*["\']', header)
20
+ if vb_match:
21
+ w_px = int(float(vb_match.group(1)) * scale)
22
+ h_px = int(float(vb_match.group(2)) * scale)
23
+ header = re.sub(r'width=["\'][^"\']*["\']', f'width="{w_px}"', header, count=1)
24
+ header = re.sub(r'height=["\'][^"\']*["\']', f'height="{h_px}"', header, count=1)
25
+ svg_text = svg_text[:svg_start] + header + svg_text[svg_end+1:]
26
+ return svg_text
27
+
28
+ def strip_aspose_watermark_from_svg(svg_text: str) -> str:
29
+ """
30
+ Exact search and removal strictly for the Aspose evaluation watermark block.
31
+ Validates that the matched <text>...</text> block explicitly contains
32
+ 'Created with Aspose.Slides' or 'Evaluation only' and 'Aspose'.
33
+ """
34
+ for marker in ["Created with Aspose.Slides", "Evaluation only"]:
35
+ while marker in svg_text:
36
+ idx = svg_text.find(marker)
37
+ if idx == -1:
38
+ break
39
+ start_pos = svg_text.rfind("<text", 0, idx)
40
+ end_pos = svg_text.find("</text>", idx)
41
+ if start_pos != -1 and end_pos != -1:
42
+ block = svg_text[start_pos:end_pos + 7]
43
+ if "Aspose" in block and ("Evaluation only" in block or "tp_0" in block):
44
+ svg_text = svg_text[:start_pos] + svg_text[end_pos + 7:]
45
+ else:
46
+ break
47
+ else:
48
+ break
49
+ return svg_text
50
+
51
+ def find_resvg_bin() -> Optional[Path]:
52
+ """Locates the standalone native resvg binary if installed."""
53
+ import shutil
54
+ for candidate in ["/usr/local/bin/resvg", "/usr/bin/resvg", "resvg", "resvg.exe"]:
55
+ found = shutil.which(candidate)
56
+ if found and os.access(found, os.X_OK):
57
+ return Path(found)
58
+ return None
59
+
60
+ def render_svg_to_png_bytes(svg_string: str, scale: float = 2.0) -> bytes:
61
+ """
62
+ Renders an SVG string to PNG bytes using the standalone native resvg CLI.
63
+ """
64
+ clean_svg = strip_aspose_watermark_from_svg(svg_string)
65
+ clean_svg = fix_svg_dimensions(clean_svg, scale=scale)
66
+
67
+ resvg_bin = find_resvg_bin()
68
+ if not resvg_bin:
69
+ raise RuntimeError("Standalone native resvg binary not found in PATH or /usr/local/bin/resvg.")
70
+
71
+ import tempfile
72
+ with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as in_svg, \
73
+ tempfile.NamedTemporaryFile(suffix=".png", delete=False) as out_png:
74
+ in_svg.write(clean_svg.encode("utf-8"))
75
+ in_svg.flush()
76
+ in_path = in_svg.name
77
+ out_path = out_png.name
78
+
79
+ try:
80
+ print(f"⚡ [Rasterizer] Using standalone native resvg CLI at scale={scale}", flush=True)
81
+ cmd = [str(resvg_bin), "-z", str(scale), in_path, out_path]
82
+ res = subprocess.run(cmd, capture_output=True, timeout=60)
83
+ if res.returncode == 0 and os.path.exists(out_path):
84
+ with open(out_path, "rb") as f:
85
+ return f.read()
86
+ else:
87
+ err_msg = res.stderr.decode("utf-8", errors="ignore").strip()
88
+ raise RuntimeError(f"Native resvg rasterization failed: {err_msg}")
89
+ finally:
90
+ if os.path.exists(in_path):
91
+ os.remove(in_path)
92
+ if os.path.exists(out_path):
93
+ os.remove(out_path)
94
+
95
+ def render_slide_to_png_bytes(aspose_slide, scale: float = 2.0) -> bytes:
96
+ """
97
+ Exports an Aspose slide to clean SVG in memory, strips the watermark, and
98
+ rasterizes it to high-fidelity PNG bytes.
99
+ """
100
+ svg_stream = io.BytesIO()
101
+ aspose_slide.write_as_svg(svg_stream)
102
+ raw_svg = svg_stream.getvalue().decode("utf-8", errors="ignore")
103
+ return render_svg_to_png_bytes(raw_svg, scale=scale)
@@ -0,0 +1,127 @@
1
+ import threading
2
+
3
+ class ChunkCollector:
4
+ def __init__(self):
5
+ self.chunks = []
6
+ self.max_char_per_chunk = 6000
7
+ self.chunk_overlap = 0
8
+
9
+ def add(self, text=""):
10
+ self.chunks.append(text)
11
+
12
+ def output(self):
13
+ return '\n'.join(self.chunks)
14
+
15
+ def get_all(self):
16
+ return self.chunks
17
+
18
+ def clear(self):
19
+ self.chunks = []
20
+
21
+ def get_chunked_texts(self, max_size=None, overlap=None):
22
+ if max_size is None:
23
+ max_size = self.max_char_per_chunk
24
+ if overlap is None:
25
+ overlap = self.chunk_overlap
26
+
27
+ if max_size <= 0:
28
+ raise ValueError("max_size must be greater than 0")
29
+
30
+ # Cap overlap to ensure strictly valid forward chunking
31
+ overlap = max(0, min(overlap, max_size - 1))
32
+
33
+ full_text = '\n'.join(self.chunks)
34
+ if not full_text:
35
+ return []
36
+
37
+ text_length = len(full_text)
38
+ if text_length <= max_size:
39
+ stripped = full_text.strip()
40
+ return [stripped] if stripped else []
41
+
42
+ chunks = []
43
+ start = 0
44
+
45
+ while start < text_length:
46
+ # Determine candidate end boundary
47
+ end = min(start + max_size, text_length)
48
+
49
+ # If not at the end of the text, break at a whitespace boundary
50
+ if end < text_length and full_text[end] not in (' ', '\n', '\t'):
51
+ walk = end
52
+ while walk > start and full_text[walk] not in (' ', '\n', '\t'):
53
+ walk -= 1
54
+ if walk > start:
55
+ end = walk
56
+
57
+ chunk = full_text[start:end].strip()
58
+ if chunk:
59
+ chunks.append(chunk)
60
+
61
+ if end >= text_length:
62
+ break
63
+
64
+ # Determine next chunk starting position
65
+ if end - overlap > start:
66
+ target_start = end - overlap
67
+
68
+ # Snap target_start cleanly to word boundary without cutting tokens
69
+ if full_text[target_start] not in (' ', '\n', '\t') and full_text[target_start - 1] not in (' ', '\n', '\t'):
70
+ walk = target_start
71
+ while walk > start and full_text[walk - 1] not in (' ', '\n', '\t'):
72
+ walk -= 1
73
+ if walk > start:
74
+ target_start = walk
75
+ else:
76
+ walk = target_start
77
+ while walk < end and full_text[walk] not in (' ', '\n', '\t'):
78
+ walk += 1
79
+ while walk < end and full_text[walk] in (' ', '\n', '\t'):
80
+ walk += 1
81
+ target_start = walk
82
+ else:
83
+ target_start = end
84
+
85
+ # Skip leading inline whitespace
86
+ while target_start < text_length and full_text[target_start] in (' ', '\t'):
87
+ target_start += 1
88
+
89
+ # Guarantee strict forward progress to completely prevent infinite loops
90
+ start = max(start + 1, target_start)
91
+
92
+ return chunks
93
+
94
+ class _ThreadLocalChunkCollector:
95
+ """Thread-isolated proxy for ChunkCollector so concurrent requests never share state."""
96
+ def __init__(self):
97
+ self._local = threading.local()
98
+
99
+ def _get_instance(self) -> ChunkCollector:
100
+ if not hasattr(self._local, "instance"):
101
+ self._local.instance = ChunkCollector()
102
+ return self._local.instance
103
+
104
+ def add(self, text=""):
105
+ self._get_instance().add(text)
106
+
107
+ def output(self):
108
+ return self._get_instance().output()
109
+
110
+ def get_all(self):
111
+ return self._get_instance().get_all()
112
+
113
+ def clear(self):
114
+ self._get_instance().clear()
115
+
116
+ @property
117
+ def max_char_per_chunk(self):
118
+ return self._get_instance().max_char_per_chunk
119
+
120
+ @property
121
+ def chunk_overlap(self):
122
+ return self._get_instance().chunk_overlap
123
+
124
+ def get_chunked_texts(self, max_size=None, overlap=None):
125
+ return self._get_instance().get_chunked_texts(max_size=max_size, overlap=overlap)
126
+
127
+ collector = _ThreadLocalChunkCollector()
@@ -0,0 +1,167 @@
1
+ from lxml import etree
2
+ import zipfile
3
+ import logging
4
+ from typing import Iterator, Optional, Dict, Any
5
+ from docx.text.paragraph import Paragraph
6
+ from dataclasses import dataclass
7
+ import hashlib
8
+
9
+ logger = logging.getLogger("super_document_processor.docx_images")
10
+
11
+ @dataclass
12
+ class ElementMetadata:
13
+ filename: Optional[str] = None
14
+ page_number: Optional[int] = None
15
+ last_modified: Optional[str] = None
16
+
17
+ @dataclass
18
+ class ImageElement:
19
+ text: str
20
+ metadata: ElementMetadata
21
+ detection_origin: str = "docx"
22
+ image_alt_text: Optional[str] = None
23
+ image_title: Optional[str] = None
24
+ image_name: Optional[str] = None
25
+ image_r_id: Optional[str] = None
26
+
27
+ class CustomImagePartitioner:
28
+ """Custom image partitioner to extract images from DOCX paragraphs."""
29
+
30
+ @classmethod
31
+ def iter_elements(cls, paragraph: Paragraph, opts) -> Iterator[ImageElement]:
32
+ """Generate Image elements for each picture found in the paragraph."""
33
+ # Find all drawing elements (images) in the paragraph
34
+ drawing_elements = paragraph._p.xpath('.//w:drawing')
35
+ for drawing in drawing_elements:
36
+ try:
37
+ image_info = cls._extract_image_info(drawing, opts)
38
+ if image_info:
39
+ yield cls._create_image_element(image_info, opts)
40
+ except Exception as e:
41
+ # Continue processing even if one image fails
42
+ logger.warning(f"Could not extract image: {e}")
43
+ continue
44
+
45
+ @classmethod
46
+ def _extract_image_info(cls, drawing_element, opts) -> Optional[Dict[str, Any]]:
47
+ """Extract image metadata from drawing element."""
48
+ # Get image relationship ID
49
+ blip_elements = drawing_element.xpath('.//a:blip/@r:embed')
50
+ if not blip_elements:
51
+ return None
52
+ r_id = blip_elements[0]
53
+
54
+ # Get image description/alt text
55
+ desc_elements = drawing_element.xpath('.//wp:docPr/@descr')
56
+ alt_text = desc_elements[0] if desc_elements else None
57
+
58
+ # Get image title
59
+ title_elements = drawing_element.xpath('.//wp:docPr/@title')
60
+ title = title_elements[0] if title_elements else None
61
+
62
+ # Get image name
63
+ name_elements = drawing_element.xpath('.//wp:docPr/@name')
64
+ name = name_elements[0] if name_elements else f"Image_{r_id}"
65
+
66
+ return {
67
+ 'r_id': r_id,
68
+ 'alt_text': alt_text,
69
+ 'title': title,
70
+ 'name': name
71
+ }
72
+
73
+ @classmethod
74
+ def _create_image_element(cls, image_info: Dict[str, Any], opts) -> ImageElement:
75
+ """Create Image element with metadata."""
76
+
77
+ text_parts = []
78
+ if image_info.get('title'):
79
+ text_parts.append(f"Title: {image_info['title']}")
80
+ if image_info.get('alt_text'):
81
+ text_parts.append(f"Description: {image_info['alt_text']}")
82
+ text_parts.append(f"Name: {image_info['name']}")
83
+
84
+ text = " | ".join(text_parts) if text_parts else f"Image: {image_info['name']}"
85
+
86
+ # Create metadata with only supported parameters
87
+ metadata = ElementMetadata(
88
+ filename=getattr(opts, 'metadata_file_path', None),
89
+ page_number=getattr(opts, 'metadata_page_number', None),
90
+ last_modified=getattr(opts, 'last_modified', None)
91
+ )
92
+
93
+ # Create and return the ImageElement dataclass
94
+ image_element = ImageElement(
95
+ text=text,
96
+ metadata=metadata,
97
+ detection_origin="docx",
98
+ image_alt_text=image_info.get('alt_text'),
99
+ image_title=image_info.get('title'),
100
+ image_name=image_info.get('name'),
101
+ image_r_id=image_info.get('r_id')
102
+ )
103
+
104
+ return image_element
105
+
106
+ def quick_image_check(filename):
107
+ """Quick check to see if DOCX contains images by inspecting media files inside the archive"""
108
+ try:
109
+ with zipfile.ZipFile(filename, 'r') as docx_zip:
110
+ files = docx_zip.namelist()
111
+ media_files = [f for f in files if f.startswith('word/media/')]
112
+ image_count = len(media_files)
113
+
114
+ return image_count > 0
115
+
116
+ except Exception as e:
117
+ logger.error(f"Error checking images: {e}")
118
+ return False
119
+
120
+ def extract_images_from_docx(filename):
121
+ extracted_images = []
122
+ seen_image_hashes = {}
123
+ try:
124
+ with zipfile.ZipFile(filename, 'r') as docx_zip:
125
+ # Parse the relationships XML
126
+ rels_xml = docx_zip.read('word/_rels/document.xml.rels')
127
+ rels_root = etree.fromstring(rels_xml)
128
+
129
+ # Build a map rId -> target path
130
+ rid_to_target = {}
131
+ for rel in rels_root.findall('{http://schemas.openxmlformats.org/package/2006/relationships}Relationship'):
132
+ rId = rel.get('Id')
133
+ target = rel.get('Target')
134
+ rid_to_target[rId] = target
135
+
136
+ # List media files in docx
137
+ media_files = [f for f in docx_zip.namelist() if f.startswith('word/media/')]
138
+
139
+ # Reverse map target path to rId (normalize with 'word/' prefix removed)
140
+ target_to_rid = {v.lstrip('/'): k for k, v in rid_to_target.items()}
141
+
142
+ for media_file in media_files:
143
+ img_data = docx_zip.read(media_file)
144
+ # Compute hash for deduplication
145
+ img_hash = hashlib.sha256(img_data).hexdigest()
146
+ if img_hash in seen_image_hashes:
147
+ continue # Skip duplicate image
148
+ seen_image_hashes[img_hash] = True
149
+
150
+ # Normalize relative path (remove leading 'word/')
151
+ relative_path = media_file[len('word/'):]
152
+
153
+ # Lookup rId by target filename
154
+ r_id = target_to_rid.get(relative_path)
155
+
156
+ extracted_images.append({
157
+ 'filename': media_file,
158
+ 'data': img_data,
159
+ 'r_id': r_id,
160
+ 'hash': img_hash,
161
+ })
162
+
163
+ return extracted_images
164
+
165
+ except Exception as e:
166
+ logger.error(f"Error extracting images: {e}")
167
+ return []
@@ -0,0 +1,73 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("super_document_processor.docx_xml")
4
+
5
+ try:
6
+ from super_document_processor.utils.chunker import collector
7
+ except ImportError:
8
+ from utils.chunker import collector
9
+
10
+ def print_list_item(text, level, marker=None):
11
+ """Print list item with proper indentation and marker (ordered number or bullet)"""
12
+ indent = " " * level
13
+ bullet_symbol = marker if marker is not None else "-"
14
+ collector.add(f"{indent}{bullet_symbol} {text}")
15
+
16
+ def extract_footnotes(docx_path):
17
+ """Extracts footnotes from a DOCX file."""
18
+ import zipfile
19
+ from xml.etree import ElementTree as ET
20
+ footnotes = []
21
+ try:
22
+ with zipfile.ZipFile(docx_path) as docx:
23
+ if 'word/footnotes.xml' not in docx.namelist():
24
+ # print("No footnotes.xml found in DOCX.")
25
+ return footnotes
26
+ xml_content = docx.read('word/footnotes.xml')
27
+ tree = ET.fromstring(xml_content)
28
+ # WordprocessingML namespace
29
+ ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
30
+ for footnote in tree.findall('w:footnote', ns):
31
+ texts = []
32
+ for paragraph in footnote.findall('.//w:p', ns):
33
+ for run in paragraph.findall('.//w:t', ns):
34
+ texts.append(run.text)
35
+ full_text = ''.join(texts).strip()
36
+ if full_text:
37
+ footnotes.append(full_text)
38
+ return footnotes
39
+ except Exception as e:
40
+ logger.warning(f"Could not extract footnotes: {e}")
41
+ return []
42
+
43
+ def extract_endnotes(docx_path):
44
+ """Extracts endnotes from a DOCX file."""
45
+ import zipfile
46
+ from xml.etree import ElementTree as ET
47
+
48
+ endnotes = []
49
+
50
+ try:
51
+ with zipfile.ZipFile(docx_path) as docx:
52
+ if 'word/endnotes.xml' not in docx.namelist():
53
+ # print("No endnotes.xml found in DOCX.")
54
+ return endnotes
55
+
56
+ xml_content = docx.read('word/endnotes.xml')
57
+ tree = ET.fromstring(xml_content)
58
+
59
+ ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
60
+
61
+ for endnote in tree.findall('w:endnote', ns):
62
+ texts = []
63
+ for paragraph in endnote.findall('.//w:p', ns):
64
+ for run in paragraph.findall('.//w:t', ns):
65
+ texts.append(run.text)
66
+ full_text = ''.join(texts).strip()
67
+ if full_text:
68
+ endnotes.append(full_text)
69
+
70
+ return endnotes
71
+ except Exception as e:
72
+ logger.warning(f"Could not extract endnotes: {e}")
73
+ return []
@@ -0,0 +1,49 @@
1
+ import sys
2
+ import os
3
+ import logging
4
+ import importlib.util
5
+
6
+ logger = logging.getLogger("super_document_processor.optimizer")
7
+
8
+ def prune_environment(verbose: bool = False) -> int:
9
+ """
10
+ Scans the installed 'aspose' package in the active environment and deletes:
11
+ 1. Redundant native C-extensions compiled for Python versions other than the current running one (e.g. cp35-cp312 on cp313).
12
+ 2. IDE type stubs (.pyi) and non-matching platform static archives.
13
+
14
+ Returns total bytes freed.
15
+ """
16
+ total_freed = 0
17
+ try:
18
+ spec = importlib.util.find_spec("aspose")
19
+ if not spec or not spec.submodule_search_locations:
20
+ return 0
21
+ aspose_dir = spec.submodule_search_locations[0]
22
+ cur_tag = f"cp{sys.version_info.major}{sys.version_info.minor}"
23
+
24
+ for root, _, files in os.walk(aspose_dir):
25
+ for f in files:
26
+ nl = f.lower()
27
+ # Check for other python version binaries
28
+ is_other_py = (
29
+ (".pyd" in nl or ".so" in nl)
30
+ and "cp" in nl
31
+ and cur_tag not in nl
32
+ )
33
+ is_stub = nl.endswith(".pyi")
34
+
35
+ if is_other_py or is_stub:
36
+ fp = os.path.join(root, f)
37
+ try:
38
+ sz = os.path.getsize(fp)
39
+ os.remove(fp)
40
+ total_freed += sz
41
+ except (OSError, PermissionError):
42
+ pass
43
+
44
+ if verbose and total_freed > 0:
45
+ logger.info(f"⚡ [SuperDocProcessor] Automatically reclaimed {total_freed / (1024*1024):.1f} MB of redundant binaries.")
46
+ except Exception as e:
47
+ logger.debug(f"Environment auto-prune skipped: {e}")
48
+
49
+ return total_freed