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