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,170 @@
1
+ import io
2
+ import os
3
+ import gc
4
+ import zipfile
5
+ import base64
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Optional, Union, Dict, Any, List
9
+ from concurrent.futures import ThreadPoolExecutor
10
+
11
+ try:
12
+ from super_document_processor.utils.aspose_slide_renderer import (
13
+ strip_aspose_watermark_from_svg,
14
+ fix_svg_dimensions,
15
+ render_svg_to_png_bytes,
16
+ render_slide_to_png_bytes
17
+ )
18
+ except ImportError:
19
+ from utils.aspose_slide_renderer import (
20
+ strip_aspose_watermark_from_svg,
21
+ fix_svg_dimensions,
22
+ render_svg_to_png_bytes,
23
+ render_slide_to_png_bytes
24
+ )
25
+
26
+ logger = logging.getLogger("super_document_processor.pptx_converter")
27
+
28
+ slides = None
29
+
30
+ def _ensure_aspose():
31
+ global slides
32
+ if slides is None:
33
+ import aspose.slides as _slides
34
+ slides = _slides
35
+
36
+ def render_slide_to_svg_string(aspose_slide, scale: float = 2.0) -> str:
37
+ """
38
+ Exports a single Aspose slide object to a cleaned, watermark-stripped SVG string.
39
+ """
40
+ svg_stream = io.BytesIO()
41
+ aspose_slide.write_as_svg(svg_stream)
42
+ raw_svg = svg_stream.getvalue().decode("utf-8", errors="ignore")
43
+ clean_svg = strip_aspose_watermark_from_svg(raw_svg)
44
+ return fix_svg_dimensions(clean_svg, scale=scale)
45
+
46
+ def convert_pptx(
47
+ pptx_source: Union[str, Path, bytes, io.BytesIO],
48
+ format: str = "svg",
49
+ slide: Optional[int] = None,
50
+ scale: float = 2.0
51
+ ) -> Dict[str, Any]:
52
+ """
53
+ Converts slides of a PowerPoint (.pptx) file to SVG or PNG images.
54
+
55
+ Args:
56
+ pptx_source: File path (str/Path) or raw file bytes.
57
+ format: Target format - 'svg' or 'png' (also accepts 'img', 'image').
58
+ slide: Optional 1-based slide index. If None, converts all slides.
59
+ scale: Resolution scaling multiplier (default 2.0).
60
+
61
+ Returns:
62
+ Dict containing total slides, format, and slide content payloads.
63
+ """
64
+ _ensure_aspose()
65
+ norm_format = "png" if format.lower() in ("png", "img", "image") else "svg"
66
+
67
+ # Load presentation from file path or in-memory byte stream
68
+ if isinstance(pptx_source, (str, Path)):
69
+ if not os.path.exists(str(pptx_source)):
70
+ raise FileNotFoundError(f"PPTX file not found: {pptx_source}")
71
+ pres = slides.Presentation(str(pptx_source))
72
+ elif isinstance(pptx_source, bytes):
73
+ pres = slides.Presentation(io.BytesIO(pptx_source))
74
+ elif isinstance(pptx_source, io.BytesIO):
75
+ pres = slides.Presentation(pptx_source)
76
+ else:
77
+ raise ValueError("Invalid pptx_source. Must be a file path, bytes, or BytesIO.")
78
+
79
+ try:
80
+ total_slides = len(pres.slides)
81
+ if total_slides == 0:
82
+ raise ValueError("The presentation contains no slides.")
83
+
84
+ if slide is not None:
85
+ if slide < 1 or slide > total_slides:
86
+ raise ValueError(f"Slide index {slide} out of range (total slides: {total_slides}).")
87
+
88
+ aspose_slide = pres.slides[slide - 1]
89
+ svg_content = render_slide_to_svg_string(aspose_slide, scale=scale)
90
+
91
+ if norm_format == "svg":
92
+ return {
93
+ "slide": slide,
94
+ "total_slides": total_slides,
95
+ "format": "svg",
96
+ "content": svg_content,
97
+ "raw_bytes": svg_content.encode("utf-8")
98
+ }
99
+ else:
100
+ png_bytes = render_svg_to_png_bytes(svg_content, scale=scale)
101
+ return {
102
+ "slide": slide,
103
+ "total_slides": total_slides,
104
+ "format": "png",
105
+ "content_base64": base64.b64encode(png_bytes).decode("utf-8"),
106
+ "raw_bytes": png_bytes
107
+ }
108
+
109
+ # Convert all slides
110
+ workers = min(int(os.environ.get("MAX_WORKERS", 3)), total_slides)
111
+ results = []
112
+
113
+ def _process_single(idx_slide_tuple):
114
+ idx, s = idx_slide_tuple
115
+ svg_str = render_slide_to_svg_string(s, scale=scale)
116
+ if norm_format == "svg":
117
+ return {
118
+ "slide": idx,
119
+ "format": "svg",
120
+ "content": svg_str,
121
+ "raw_bytes": svg_str.encode("utf-8")
122
+ }
123
+ else:
124
+ png_b = render_svg_to_png_bytes(svg_str, scale=scale)
125
+ return {
126
+ "slide": idx,
127
+ "format": "png",
128
+ "content_base64": base64.b64encode(png_b).decode("utf-8"),
129
+ "raw_bytes": png_b
130
+ }
131
+
132
+ slide_tuples = list(enumerate(pres.slides, start=1))
133
+ if workers > 1:
134
+ with ThreadPoolExecutor(max_workers=workers) as executor:
135
+ results = list(executor.map(_process_single, slide_tuples))
136
+ else:
137
+ results = [_process_single(st) for st in slide_tuples]
138
+
139
+ results.sort(key=lambda x: x["slide"])
140
+
141
+ return {
142
+ "total_slides": total_slides,
143
+ "format": norm_format,
144
+ "slides": results
145
+ }
146
+ finally:
147
+ try:
148
+ del pres
149
+ except Exception:
150
+ pass
151
+ gc.collect()
152
+
153
+ def create_pptx_render_zip(
154
+ pptx_source: Union[str, Path, bytes, io.BytesIO],
155
+ format: str = "svg",
156
+ scale: float = 2.0
157
+ ) -> bytes:
158
+ """
159
+ Renders all slides in a PPTX and bundles them into a ZIP archive.
160
+ """
161
+ res = convert_pptx(pptx_source, format=format, slide=None, scale=scale)
162
+ ext = "svg" if res["format"] == "svg" else "png"
163
+
164
+ zip_buffer = io.BytesIO()
165
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
166
+ for item in res["slides"]:
167
+ filename = f"slide_{item['slide']}.{ext}"
168
+ zip_file.writestr(filename, item["raw_bytes"])
169
+
170
+ return zip_buffer.getvalue()
@@ -0,0 +1,176 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import time
5
+ import urllib.request
6
+ import urllib.error
7
+
8
+ logger = logging.getLogger("super_document_processor.vlm")
9
+
10
+ try:
11
+ from super_document_processor.processor import OPENAI_API_KEY, OPENAI_BASE_URL
12
+ except ImportError:
13
+ from processor import OPENAI_API_KEY, OPENAI_BASE_URL
14
+
15
+ def is_vision_unsupported_error(error_msg: str) -> bool:
16
+ """Detect if the provider specifically rejected image/multimodal input."""
17
+ err_lower = error_msg.lower()
18
+ vision_error_indicators = [
19
+ "image_url",
20
+ "vision",
21
+ "multimodal",
22
+ "does not support image",
23
+ "expected a string, got an object",
24
+ "unsupported parameter: messages",
25
+ "invalid parameter: messages",
26
+ "model does not support",
27
+ "unknown parameter: 'image_url'",
28
+ ]
29
+ return any(indicator in err_lower for indicator in vision_error_indicators)
30
+
31
+ def is_invalid_model_error(error_msg: str) -> bool:
32
+ """Detect if the error is due to an unknown or invalid model name."""
33
+ err_lower = error_msg.lower()
34
+ model_error_indicators = [
35
+ "invalid model",
36
+ "model not found",
37
+ "model does not exist",
38
+ "unknown model",
39
+ "no such model",
40
+ "model_not_found",
41
+ "invalid model name",
42
+ "/v1/models",
43
+ "does not exist or you do not have access",
44
+ ]
45
+ return any(indicator in err_lower for indicator in model_error_indicators)
46
+
47
+ def is_auth_error(error_msg: str) -> bool:
48
+ """Detect authentication or permission errors."""
49
+ err_lower = error_msg.lower()
50
+ auth_indicators = [
51
+ "invalid api key",
52
+ "incorrect api key",
53
+ "unauthorized",
54
+ "authentication",
55
+ "permission_denied",
56
+ "status code 401",
57
+ "code: 401",
58
+ "status code 403",
59
+ "code: 403",
60
+ ]
61
+ return any(indicator in err_lower for indicator in auth_indicators)
62
+
63
+ def generate_image_description(image_bytes, model=None, max_retries=3, retry_delay=2):
64
+ base_url = (OPENAI_BASE_URL).rstrip("/")
65
+ api_key = OPENAI_API_KEY
66
+
67
+ is_local = "localhost" in base_url or "127.0.0.1" in base_url
68
+ if not api_key and not is_local:
69
+ raise ValueError(
70
+ "Cannot process images: OPENAI_API_KEY is not set in the environment.\n"
71
+ "Please set OPENAI_API_KEY or disable image processing."
72
+ )
73
+
74
+ if not model:
75
+ raise ValueError(
76
+ "Cannot process images: No vision model specified.\n"
77
+ "Please specify a vision-capable model via CLI (--model) or API ('model' parameter)."
78
+ )
79
+
80
+ base64_image = base64.b64encode(image_bytes).decode("utf-8")
81
+ image_url = f"data:image/jpeg;base64,{base64_image}"
82
+
83
+ endpoint = f"{base_url}/chat/completions"
84
+ payload = {
85
+ "model": model,
86
+ "messages": [
87
+ {
88
+ "role": "system",
89
+ "content": """
90
+ You are an advanced multimodal AI specialized in both high-fidelity data extraction and detailed image description.
91
+
92
+ ### TASK GUIDELINES:
93
+ 1. **Table Extraction (High Priority)**: If the image contains a table, you must recreate it perfectly as a **Markdown Table**.
94
+ - Include every row and column.
95
+ - Do not use <br> tags or HTML formatting inside cells.
96
+ - Do not summarize the data; transcribe it 100% accurately.
97
+ 2. **Text Transcription**: Transcribe all other visible text, maintaining logical structure (headings, lists).
98
+ 3. **Visual Analysis**: For photographs, diagrams, or UI layouts, provide a detailed description of objects, people, colors, and spatial relationships.
99
+ 4. **Logical Structure**: Organize your response with clear Markdown headers (e.g., ## Table Data, ## Visual Description).
100
+
101
+ ### RULES:
102
+ - Prioritize the **content** of the image. If a table is present, it should be the most prominent part of your response.
103
+ - Do not use conversational preambles (e.g., "In this image, I can see...").
104
+ - Maintain objectivity and do not infer meaning beyond what is shown.
105
+ """
106
+ },
107
+ {
108
+ "role": "user",
109
+ "content": [
110
+ {"type": "text", "text": "Analyze this image in detail. If there are tables, recreate them fully as markdown tables. Transcribe all text and describe all visual elements."},
111
+ {"type": "image_url", "image_url": {"url": image_url}}
112
+ ]
113
+ }
114
+ ],
115
+ "temperature": 0.0,
116
+ "max_tokens": 4096,
117
+ "stream": False,
118
+ }
119
+
120
+ req_data = json.dumps(payload).encode("utf-8")
121
+ headers = {
122
+ "Content-Type": "application/json",
123
+ "User-Agent": "SuperDocumentProcessor/1.0"
124
+ }
125
+ if api_key:
126
+ headers["Authorization"] = f"Bearer {api_key}"
127
+
128
+ for attempt in range(1, max_retries + 1):
129
+ try:
130
+ req = urllib.request.Request(endpoint, data=req_data, headers=headers, method="POST")
131
+ with urllib.request.urlopen(req, timeout=60) as resp:
132
+ res_json = json.loads(resp.read().decode("utf-8"))
133
+ description = res_json["choices"][0]["message"]["content"].strip()
134
+ return description
135
+ except urllib.error.HTTPError as e:
136
+ try:
137
+ err_body = e.read().decode("utf-8", errors="ignore")
138
+ except Exception:
139
+ err_body = ""
140
+ err_msg = f"HTTP {e.code}: {e.reason} - {err_body}"
141
+
142
+ # Fast-fail immediately if the model is invalid/unknown - DO NOT retry
143
+ if is_invalid_model_error(err_msg):
144
+ raise ValueError(
145
+ f"Invalid model '{model}' specified for vision processing!\n"
146
+ f"Provider Error: {err_msg}"
147
+ ) from e
148
+
149
+ # Fast-fail immediately if authentication or permissions fail - DO NOT retry
150
+ if is_auth_error(err_msg):
151
+ raise ValueError(
152
+ f"Authentication error with configured LLM provider!\n"
153
+ f"Provider Error: {err_msg}"
154
+ ) from e
155
+
156
+ # Fast-fail immediately if the model doesn't support images - DO NOT retry
157
+ if is_vision_unsupported_error(err_msg):
158
+ raise ValueError(
159
+ f"Configured model '{model}' does not support vision/image inputs!\n"
160
+ f"Provider Error: {err_msg}\n"
161
+ f"Fix: Pass a vision-capable model (e.g. 'gpt-4o', 'google-gemini-3-flash') "
162
+ f"or disable image processing."
163
+ ) from e
164
+
165
+ logger.warning(f"VLM attempt {attempt} failed: {err_msg}")
166
+ if attempt < max_retries:
167
+ time.sleep(retry_delay)
168
+ else:
169
+ return f"Image (description generation failed: {err_msg})"
170
+ except Exception as e:
171
+ err_msg = str(e)
172
+ logger.warning(f"VLM attempt {attempt} failed: {err_msg}")
173
+ if attempt < max_retries:
174
+ time.sleep(retry_delay)
175
+ else:
176
+ return f"Image (description generation failed: {err_msg})"