multixtract 0.1.1__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.
- multixtract/__init__.py +52 -0
- multixtract/chunking.py +297 -0
- multixtract/cli.py +75 -0
- multixtract/extraction.py +44 -0
- multixtract/extractors/__init__.py +84 -0
- multixtract/extractors/_image_utils.py +157 -0
- multixtract/extractors/docx.py +375 -0
- multixtract/extractors/eml.py +187 -0
- multixtract/extractors/epub.py +142 -0
- multixtract/extractors/excel.py +321 -0
- multixtract/extractors/html.py +127 -0
- multixtract/extractors/image.py +149 -0
- multixtract/extractors/legacy.py +148 -0
- multixtract/extractors/markdown.py +104 -0
- multixtract/extractors/pdf.py +347 -0
- multixtract/extractors/pptx.py +303 -0
- multixtract/extractors/registry.py +72 -0
- multixtract/extractors/rtf.py +54 -0
- multixtract/extractors/text.py +50 -0
- multixtract/filters.py +181 -0
- multixtract/interfaces.py +151 -0
- multixtract/pipeline.py +250 -0
- multixtract/providers/__init__.py +38 -0
- multixtract/providers/_utils.py +32 -0
- multixtract/providers/azure.py +91 -0
- multixtract/providers/llama.py +151 -0
- multixtract/providers/openai.py +125 -0
- multixtract/providers/qwen2vl.py +158 -0
- multixtract/providers/smolvlm.py +152 -0
- multixtract/providers/storage.py +97 -0
- multixtract/py.typed +1 -0
- multixtract/vision.py +120 -0
- multixtract-0.1.1.dist-info/METADATA +419 -0
- multixtract-0.1.1.dist-info/RECORD +37 -0
- multixtract-0.1.1.dist-info/WHEEL +4 -0
- multixtract-0.1.1.dist-info/entry_points.txt +2 -0
- multixtract-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Email extractor (.eml).
|
|
2
|
+
|
|
3
|
+
Uses only stdlib — no optional deps required.
|
|
4
|
+
Image attachments and inline images (Content-ID) are extracted and passed
|
|
5
|
+
through the shared ImageFilterPipeline when one is provided.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import email
|
|
10
|
+
import email.policy
|
|
11
|
+
import email.utils
|
|
12
|
+
import io
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("multixtract")
|
|
19
|
+
|
|
20
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
21
|
+
|
|
22
|
+
_IMAGE_MIME = {
|
|
23
|
+
"image/png", "image/jpeg", "image/jpg", "image/gif",
|
|
24
|
+
"image/bmp", "image/tiff", "image/webp",
|
|
25
|
+
}
|
|
26
|
+
_EXT_NORM = {"jpg": "jpeg", "tif": "tiff"}
|
|
27
|
+
_MIME_TO_EXT = {
|
|
28
|
+
"image/png": "png",
|
|
29
|
+
"image/jpeg": "jpeg",
|
|
30
|
+
"image/jpg": "jpeg",
|
|
31
|
+
"image/gif": "gif",
|
|
32
|
+
"image/bmp": "bmp",
|
|
33
|
+
"image/tiff": "tiff",
|
|
34
|
+
"image/webp": "webp",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _strip_html(html: str) -> str:
|
|
39
|
+
return re.sub(r"\s+", " ", _TAG_RE.sub(" ", html)).strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _decode_part(part) -> str:
|
|
43
|
+
charset = part.get_content_charset() or "utf-8"
|
|
44
|
+
payload = part.get_payload(decode=True)
|
|
45
|
+
if not payload:
|
|
46
|
+
return ""
|
|
47
|
+
return payload.decode(charset, errors="replace")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_body(msg) -> str:
|
|
51
|
+
plain = html = ""
|
|
52
|
+
for part in msg.walk():
|
|
53
|
+
cd = str(part.get("Content-Disposition") or "")
|
|
54
|
+
if "attachment" in cd.lower():
|
|
55
|
+
continue
|
|
56
|
+
ct = part.get_content_type()
|
|
57
|
+
if ct == "text/plain" and not plain:
|
|
58
|
+
plain = _decode_part(part)
|
|
59
|
+
elif ct == "text/html" and not html:
|
|
60
|
+
html = _decode_part(part)
|
|
61
|
+
if plain:
|
|
62
|
+
return plain
|
|
63
|
+
if html:
|
|
64
|
+
return _strip_html(html)
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_attachments(msg) -> List[str]:
|
|
69
|
+
names = []
|
|
70
|
+
for part in msg.walk():
|
|
71
|
+
cd = str(part.get("Content-Disposition") or "")
|
|
72
|
+
if "attachment" not in cd.lower():
|
|
73
|
+
continue
|
|
74
|
+
filename = part.get_filename() or ""
|
|
75
|
+
if filename:
|
|
76
|
+
names.append(filename)
|
|
77
|
+
return names
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_date(raw: str) -> str:
|
|
81
|
+
try:
|
|
82
|
+
dt = email.utils.parsedate_to_datetime(raw)
|
|
83
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
84
|
+
except Exception:
|
|
85
|
+
return raw
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EmlExtractor:
|
|
89
|
+
"""DocumentExtractor for .eml email files."""
|
|
90
|
+
|
|
91
|
+
extensions: Tuple[str, ...] = (".eml",)
|
|
92
|
+
|
|
93
|
+
def extract(
|
|
94
|
+
self,
|
|
95
|
+
path: str,
|
|
96
|
+
image_filter: Optional[Any] = None,
|
|
97
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
98
|
+
base_name = os.path.splitext(os.path.basename(path))[0]
|
|
99
|
+
empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
|
|
100
|
+
try:
|
|
101
|
+
with open(path, "rb") as fh:
|
|
102
|
+
raw = fh.read()
|
|
103
|
+
|
|
104
|
+
msg = email.message_from_bytes(raw, policy=email.policy.compat32)
|
|
105
|
+
|
|
106
|
+
subject = str(msg.get("Subject") or "")
|
|
107
|
+
from_ = str(msg.get("From") or "")
|
|
108
|
+
to = str(msg.get("To") or "")
|
|
109
|
+
raw_date = str(msg.get("Date") or "")
|
|
110
|
+
date = _parse_date(raw_date) if raw_date else ""
|
|
111
|
+
|
|
112
|
+
body = _extract_body(msg)
|
|
113
|
+
attachments = _extract_attachments(msg)
|
|
114
|
+
|
|
115
|
+
header_block = (
|
|
116
|
+
f"Subject: {subject}\n"
|
|
117
|
+
f"From: {from_}\n"
|
|
118
|
+
f"To: {to}\n"
|
|
119
|
+
f"Date: {date}"
|
|
120
|
+
)
|
|
121
|
+
txt = f"{header_block}\n\n{body}".strip()
|
|
122
|
+
|
|
123
|
+
document = {
|
|
124
|
+
"_base_name": base_name,
|
|
125
|
+
"metadata": {
|
|
126
|
+
"page_count": 1,
|
|
127
|
+
"format": "eml",
|
|
128
|
+
"subject": subject,
|
|
129
|
+
"from": from_,
|
|
130
|
+
"to": to,
|
|
131
|
+
"date": date,
|
|
132
|
+
"attachment_count": len(attachments),
|
|
133
|
+
"attachments": attachments,
|
|
134
|
+
},
|
|
135
|
+
"pgs": [
|
|
136
|
+
{"pg_num": 1, "txt": txt, "tables": [], "imgs": []}
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# ── Images ──────────────────────────────────────────────────────
|
|
141
|
+
# Extract image attachments and inline images (Content-ID parts).
|
|
142
|
+
prepared_images: List[Dict[str, Any]] = []
|
|
143
|
+
if image_filter is not None:
|
|
144
|
+
try:
|
|
145
|
+
from PIL import Image as PILImage
|
|
146
|
+
except ImportError:
|
|
147
|
+
PILImage = None # type: ignore[assignment]
|
|
148
|
+
|
|
149
|
+
if PILImage is not None:
|
|
150
|
+
img_idx = 0
|
|
151
|
+
for part in msg.walk():
|
|
152
|
+
ct = part.get_content_type().lower()
|
|
153
|
+
if ct not in _IMAGE_MIME:
|
|
154
|
+
continue
|
|
155
|
+
image_bytes = part.get_payload(decode=True)
|
|
156
|
+
if not image_bytes:
|
|
157
|
+
continue
|
|
158
|
+
# Derive extension: try filename first, fall back to MIME
|
|
159
|
+
filename = part.get_filename() or ""
|
|
160
|
+
raw_ext = os.path.splitext(filename)[1].lower().lstrip(".")
|
|
161
|
+
if not raw_ext:
|
|
162
|
+
raw_ext = _MIME_TO_EXT.get(ct, "")
|
|
163
|
+
ext = _EXT_NORM.get(raw_ext, raw_ext)
|
|
164
|
+
if not ext:
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
with PILImage.open(io.BytesIO(image_bytes)) as img:
|
|
168
|
+
width, height = img.size
|
|
169
|
+
except Exception:
|
|
170
|
+
continue
|
|
171
|
+
prepared = image_filter.prepare_image(
|
|
172
|
+
image_bytes=image_bytes,
|
|
173
|
+
ext=ext,
|
|
174
|
+
width=width,
|
|
175
|
+
height=height,
|
|
176
|
+
image_id=f"{base_name}__p1_img{img_idx}",
|
|
177
|
+
page_number=1,
|
|
178
|
+
img_idx=img_idx,
|
|
179
|
+
)
|
|
180
|
+
if prepared is not None:
|
|
181
|
+
prepared_images.append(prepared)
|
|
182
|
+
img_idx += 1
|
|
183
|
+
|
|
184
|
+
return document, prepared_images
|
|
185
|
+
except Exception:
|
|
186
|
+
log.warning("EmlExtractor failed for %s", path, exc_info=True)
|
|
187
|
+
return empty, []
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""EPUB extractor (.epub).
|
|
2
|
+
|
|
3
|
+
Requires ebooklib and beautifulsoup4 (optional extra [epub]).
|
|
4
|
+
Images embedded in the EPUB ZIP (cover, diagrams, figures) are extracted and
|
|
5
|
+
passed through the shared ImageFilterPipeline.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("multixtract")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_table(table_tag) -> List[List[str]]:
|
|
18
|
+
rows = []
|
|
19
|
+
for tr in table_tag.find_all("tr"):
|
|
20
|
+
cells = [td.get_text(strip=True) for td in tr.find_all(["th", "td"])]
|
|
21
|
+
if cells:
|
|
22
|
+
rows.append(cells)
|
|
23
|
+
return rows
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_meta(book, namespace: str, key: str) -> str:
|
|
27
|
+
try:
|
|
28
|
+
return book.get_metadata(namespace, key)[0][0]
|
|
29
|
+
except (IndexError, KeyError, TypeError):
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"}
|
|
34
|
+
_EXT_NORM = {"jpg": "jpeg", "tif": "tiff"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EpubExtractor:
|
|
38
|
+
"""DocumentExtractor for EPUB files."""
|
|
39
|
+
|
|
40
|
+
extensions: Tuple[str, ...] = (".epub",)
|
|
41
|
+
|
|
42
|
+
def extract(
|
|
43
|
+
self,
|
|
44
|
+
path: str,
|
|
45
|
+
image_filter: Optional[Any] = None,
|
|
46
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
47
|
+
base_name = os.path.splitext(os.path.basename(path))[0]
|
|
48
|
+
empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
|
|
49
|
+
try:
|
|
50
|
+
import ebooklib
|
|
51
|
+
from ebooklib import epub
|
|
52
|
+
from bs4 import BeautifulSoup
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"EPUB support requires ebooklib and beautifulsoup4: "
|
|
56
|
+
"pip install 'multixtract[epub]'"
|
|
57
|
+
) from exc
|
|
58
|
+
try:
|
|
59
|
+
from PIL import Image as PILImage
|
|
60
|
+
except ImportError:
|
|
61
|
+
PILImage = None # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
book = epub.read_epub(path, options={"ignore_ncx": True})
|
|
65
|
+
|
|
66
|
+
# ── Text pages ──────────────────────────────────────────────────
|
|
67
|
+
pages = []
|
|
68
|
+
pg_num = 0
|
|
69
|
+
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
|
70
|
+
if os.path.basename(item.get_name()).lower() in {"nav.xhtml", "nav.html", "toc.xhtml"}:
|
|
71
|
+
continue
|
|
72
|
+
content = item.get_content()
|
|
73
|
+
if not content:
|
|
74
|
+
continue
|
|
75
|
+
soup = BeautifulSoup(content, "html.parser")
|
|
76
|
+
for tag in soup.find_all(["script", "style"]):
|
|
77
|
+
tag.decompose()
|
|
78
|
+
txt = soup.get_text(separator="\n", strip=True)
|
|
79
|
+
tables = [t for t in (
|
|
80
|
+
_parse_table(tbl) for tbl in soup.find_all("table")
|
|
81
|
+
) if t]
|
|
82
|
+
if not txt:
|
|
83
|
+
continue
|
|
84
|
+
pg_num += 1
|
|
85
|
+
pages.append({
|
|
86
|
+
"pg_num": pg_num,
|
|
87
|
+
"txt": txt,
|
|
88
|
+
"tables": tables,
|
|
89
|
+
"imgs": [],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
document = {
|
|
93
|
+
"_base_name": base_name,
|
|
94
|
+
"metadata": {
|
|
95
|
+
"page_count": len(pages),
|
|
96
|
+
"format": "epub",
|
|
97
|
+
"title": _get_meta(book, "DC", "title"),
|
|
98
|
+
"author": _get_meta(book, "DC", "creator"),
|
|
99
|
+
"language": _get_meta(book, "DC", "language"),
|
|
100
|
+
},
|
|
101
|
+
"pgs": pages,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# ── Images ──────────────────────────────────────────────────────
|
|
105
|
+
# EPUB images are stored as ITEM_IMAGE entries in the manifest.
|
|
106
|
+
# They aren't tied to a specific page (spine order doesn't map
|
|
107
|
+
# images to pages reliably), so all images are assigned to page 1.
|
|
108
|
+
prepared_images: List[Dict[str, Any]] = []
|
|
109
|
+
if image_filter is not None and PILImage is not None:
|
|
110
|
+
img_idx = 0
|
|
111
|
+
for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
|
|
112
|
+
raw_ext = os.path.splitext(item.get_name())[1].lower()
|
|
113
|
+
if raw_ext not in _IMAGE_EXTS:
|
|
114
|
+
continue
|
|
115
|
+
image_bytes = item.get_content()
|
|
116
|
+
if not image_bytes:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
with PILImage.open(io.BytesIO(image_bytes)) as img:
|
|
120
|
+
width, height = img.size
|
|
121
|
+
except Exception:
|
|
122
|
+
continue
|
|
123
|
+
ext = _EXT_NORM.get(raw_ext.lstrip("."), raw_ext.lstrip("."))
|
|
124
|
+
prepared = image_filter.prepare_image(
|
|
125
|
+
image_bytes=image_bytes,
|
|
126
|
+
ext=ext,
|
|
127
|
+
width=width,
|
|
128
|
+
height=height,
|
|
129
|
+
image_id=f"{base_name}__p1_img{img_idx}",
|
|
130
|
+
page_number=1,
|
|
131
|
+
img_idx=img_idx,
|
|
132
|
+
)
|
|
133
|
+
if prepared is not None:
|
|
134
|
+
prepared_images.append(prepared)
|
|
135
|
+
img_idx += 1
|
|
136
|
+
|
|
137
|
+
return document, prepared_images
|
|
138
|
+
except ImportError:
|
|
139
|
+
raise
|
|
140
|
+
except Exception:
|
|
141
|
+
log.warning("EpubExtractor failed for %s", path, exc_info=True)
|
|
142
|
+
return empty, []
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Spreadsheet extractor for ``.xlsx`` / ``.xlsm`` / ``.csv``.
|
|
2
|
+
|
|
3
|
+
Ported from the ZF CAE RAG ``parse_xlsm`` parser. Each sheet becomes one
|
|
4
|
+
*page* whose text is a row-oriented ``key: value | key: value`` rendering
|
|
5
|
+
(robust for wide / sparse engineering sheets) prefixed by a sheet header
|
|
6
|
+
(name, column list, row count). The shared :func:`chunk_document` then windows
|
|
7
|
+
that text into ~500-token chunks, so chunk sizing lives in one place instead
|
|
8
|
+
of being re-implemented per format.
|
|
9
|
+
|
|
10
|
+
Embedded images (``.xlsx`` / ``.xlsm`` only) are pulled from ``xl/media/`` and
|
|
11
|
+
best-effort mapped to their sheet via the workbook/worksheet/drawing
|
|
12
|
+
relationship chain, then filtered through :class:`ImageFilterPipeline`.
|
|
13
|
+
EMF/WMF/SVG and JPEG-XR (.wdp) reuse the shared image converters.
|
|
14
|
+
|
|
15
|
+
Requires the ``[xlsx]`` extra (openpyxl) for Excel; ``.csv`` uses only the
|
|
16
|
+
standard library.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv as _csv
|
|
21
|
+
import io
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import xml.etree.ElementTree as ET
|
|
25
|
+
import zipfile
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
28
|
+
|
|
29
|
+
from ..filters import ImageFilterPipeline
|
|
30
|
+
from ._image_utils import (
|
|
31
|
+
RASTER_EXTS,
|
|
32
|
+
VECTOR_EXTS,
|
|
33
|
+
WDP_EXTS,
|
|
34
|
+
batch_convert_vectors_to_png,
|
|
35
|
+
decode_wdp_to_png,
|
|
36
|
+
ensure_rgb_png,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger("multixtract.extractors.excel")
|
|
40
|
+
|
|
41
|
+
_PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
42
|
+
_SS_MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
43
|
+
_OFFICE_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
44
|
+
|
|
45
|
+
_MAX_HEADER_COLS = 20 # truncate the column preview in the sheet header
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _row_to_kv(headers: List[str], row) -> str:
|
|
49
|
+
"""Render one row as ``col: val | col: val`` for non-empty cells only."""
|
|
50
|
+
pairs = []
|
|
51
|
+
for h, v in zip(headers, row):
|
|
52
|
+
if v is not None and str(v).strip() != "":
|
|
53
|
+
pairs.append(f"{h}: {v}")
|
|
54
|
+
return " | ".join(pairs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_metadata_row(row) -> bool:
|
|
58
|
+
"""Return True if this row looks like a key-value metadata row rather than a column header.
|
|
59
|
+
|
|
60
|
+
Engineering spreadsheets frequently have metadata rows above the real header
|
|
61
|
+
(e.g. 'Report Date: 2024-01-01', 'Filter: Active'). The reliable signal is
|
|
62
|
+
that at least one non-empty cell ends with ':' — a label cell pattern.
|
|
63
|
+
Real column-header rows like ('Part', 'Quantity', 'Price') never end with ':'.
|
|
64
|
+
"""
|
|
65
|
+
non_empty = [str(c).strip() for c in row if c is not None and str(c).strip() != ""]
|
|
66
|
+
if not non_empty:
|
|
67
|
+
return False
|
|
68
|
+
return any(c.endswith(":") for c in non_empty)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sheet_to_text(sheet_name: str, rows: List[Tuple]) -> str:
|
|
72
|
+
"""Build header + row-oriented text for one sheet. Rows are separated by a
|
|
73
|
+
blank line so the text chunker treats each as its own unit."""
|
|
74
|
+
if not rows:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
headers: Optional[List[str]] = None
|
|
78
|
+
data_start = 0
|
|
79
|
+
for i, row in enumerate(rows):
|
|
80
|
+
if any(c is not None and str(c).strip() != "" for c in row):
|
|
81
|
+
if _is_metadata_row(row):
|
|
82
|
+
continue # skip label:value metadata rows above the real header
|
|
83
|
+
headers = [str(c) if c not in (None, "") else f"col_{j}" for j, c in enumerate(row)]
|
|
84
|
+
data_start = i + 1
|
|
85
|
+
break
|
|
86
|
+
if not headers:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
data_rows = rows[data_start:]
|
|
90
|
+
col_preview = ", ".join(headers[:_MAX_HEADER_COLS])
|
|
91
|
+
if len(headers) > _MAX_HEADER_COLS:
|
|
92
|
+
col_preview += f", ... ({len(headers)} columns total)"
|
|
93
|
+
header_block = (
|
|
94
|
+
f"Sheet: {sheet_name}\n"
|
|
95
|
+
f"Columns: {col_preview}\n"
|
|
96
|
+
f"Total rows: {len(data_rows)}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
row_texts = [t for t in (_row_to_kv(headers, r) for r in data_rows) if t]
|
|
100
|
+
if not row_texts:
|
|
101
|
+
return header_block
|
|
102
|
+
return header_block + "\n\n" + "\n\n".join(row_texts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_sheet_media_map(zf: zipfile.ZipFile) -> Dict[str, List[str]]:
|
|
106
|
+
"""Best-effort map of sheet name -> [xl/media/* paths] via the
|
|
107
|
+
workbook -> worksheet -> drawing relationship chain."""
|
|
108
|
+
names = set(zf.namelist())
|
|
109
|
+
sheet_media: Dict[str, List[str]] = defaultdict(list)
|
|
110
|
+
|
|
111
|
+
def _read_rels(rels_path: str) -> Dict[str, str]:
|
|
112
|
+
out: Dict[str, str] = {}
|
|
113
|
+
if rels_path not in names:
|
|
114
|
+
return out
|
|
115
|
+
try:
|
|
116
|
+
root = ET.fromstring(zf.read(rels_path).decode("utf-8"))
|
|
117
|
+
for rel in root.findall(f"{{{_PKG_REL_NS}}}Relationship"):
|
|
118
|
+
out[rel.get("Id", "")] = rel.get("Target", "")
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
log.debug("rels parse failed for %s: %s", rels_path, exc)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
wb_root = ET.fromstring(zf.read("xl/workbook.xml").decode("utf-8"))
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
log.warning("could not parse workbook.xml for image mapping: %s", exc)
|
|
127
|
+
return sheet_media
|
|
128
|
+
wb_rels = _read_rels("xl/_rels/workbook.xml.rels")
|
|
129
|
+
|
|
130
|
+
for sheet_el in wb_root.iter(f"{{{_SS_MAIN_NS}}}sheet"):
|
|
131
|
+
sheet_name = sheet_el.get("name", "")
|
|
132
|
+
rid = sheet_el.get(f"{{{_OFFICE_REL_NS}}}id", "")
|
|
133
|
+
target = wb_rels.get(rid, "")
|
|
134
|
+
if not sheet_name or not target:
|
|
135
|
+
continue
|
|
136
|
+
sheet_part = "xl/" + target.lstrip("/") if not target.startswith("xl/") else target
|
|
137
|
+
base = os.path.basename(sheet_part)
|
|
138
|
+
sheet_rels = _read_rels(f"xl/worksheets/_rels/{base}.rels")
|
|
139
|
+
|
|
140
|
+
for rel_target in sheet_rels.values():
|
|
141
|
+
if "drawing" not in rel_target:
|
|
142
|
+
continue
|
|
143
|
+
drawing_name = os.path.basename(rel_target)
|
|
144
|
+
drawing_rels = _read_rels(f"xl/drawings/_rels/{drawing_name}.rels")
|
|
145
|
+
for media_target in drawing_rels.values():
|
|
146
|
+
if "media/" in media_target:
|
|
147
|
+
media_name = media_target.split("media/")[-1]
|
|
148
|
+
sheet_media[sheet_name].append(f"xl/media/{media_name}")
|
|
149
|
+
return sheet_media
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ExcelExtractor:
|
|
153
|
+
"""DocumentExtractor for ``.xlsx`` / ``.xlsm`` / ``.csv``."""
|
|
154
|
+
|
|
155
|
+
extensions: Tuple[str, ...] = (".xlsx", ".xlsm", ".csv")
|
|
156
|
+
|
|
157
|
+
def __init__(self, vector_timeout: int = 120) -> None:
|
|
158
|
+
self.vector_timeout = vector_timeout
|
|
159
|
+
|
|
160
|
+
def extract(
|
|
161
|
+
self,
|
|
162
|
+
path: str,
|
|
163
|
+
image_filter: Optional[ImageFilterPipeline] = None,
|
|
164
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
165
|
+
if image_filter is None:
|
|
166
|
+
image_filter = ImageFilterPipeline()
|
|
167
|
+
image_filter.reset()
|
|
168
|
+
base_name = os.path.splitext(os.path.basename(path))[0]
|
|
169
|
+
empty: Dict[str, Any] = {"_base_name": base_name, "metadata": {}, "pgs": []}
|
|
170
|
+
try:
|
|
171
|
+
if path.lower().endswith(".csv"):
|
|
172
|
+
return self._extract_csv(path, base_name)
|
|
173
|
+
return self._extract_xlsx(path, base_name, image_filter)
|
|
174
|
+
except ImportError:
|
|
175
|
+
raise
|
|
176
|
+
except Exception:
|
|
177
|
+
log.warning("ExcelExtractor failed for %s", path, exc_info=True)
|
|
178
|
+
return empty, []
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ CSV
|
|
181
|
+
def _extract_csv(self, path: str, base_name: str) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
182
|
+
with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
|
|
183
|
+
sample = f.read(8192)
|
|
184
|
+
f.seek(0)
|
|
185
|
+
try:
|
|
186
|
+
dialect = _csv.Sniffer().sniff(sample) if sample else _csv.excel
|
|
187
|
+
except _csv.Error:
|
|
188
|
+
dialect = _csv.excel
|
|
189
|
+
rows = [tuple(r) for r in _csv.reader(f, dialect)]
|
|
190
|
+
|
|
191
|
+
txt = _sheet_to_text(base_name, rows)
|
|
192
|
+
document = {
|
|
193
|
+
"metadata": {"sheet_count": 1, "row_count": max(0, len(rows) - 1)},
|
|
194
|
+
"_base_name": base_name,
|
|
195
|
+
"pgs": [{"pg_num": 1, "kind": "sheet", "title": base_name,
|
|
196
|
+
"txt": txt, "tables": [], "imgs": []}],
|
|
197
|
+
}
|
|
198
|
+
return document, []
|
|
199
|
+
|
|
200
|
+
# ----------------------------------------------------------------- XLSX
|
|
201
|
+
def _extract_xlsx(
|
|
202
|
+
self,
|
|
203
|
+
path: str,
|
|
204
|
+
base_name: str,
|
|
205
|
+
image_filter: ImageFilterPipeline,
|
|
206
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
207
|
+
try:
|
|
208
|
+
import openpyxl
|
|
209
|
+
except ImportError as e:
|
|
210
|
+
raise ImportError(
|
|
211
|
+
"Excel support requires openpyxl: pip install 'multixtract[xlsx]'"
|
|
212
|
+
) from e
|
|
213
|
+
from PIL import Image
|
|
214
|
+
|
|
215
|
+
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
|
216
|
+
try:
|
|
217
|
+
sheet_names = list(wb.sheetnames)
|
|
218
|
+
sheet_index = {name: i + 1 for i, name in enumerate(sheet_names)}
|
|
219
|
+
|
|
220
|
+
document: Dict[str, Any] = {
|
|
221
|
+
"metadata": {"sheet_count": len(sheet_names), "sheet_names": sheet_names},
|
|
222
|
+
"_base_name": base_name,
|
|
223
|
+
"pgs": [],
|
|
224
|
+
}
|
|
225
|
+
for pg_num, name in enumerate(sheet_names, start=1):
|
|
226
|
+
ws = wb[name]
|
|
227
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
228
|
+
document["pgs"].append({
|
|
229
|
+
"pg_num": pg_num,
|
|
230
|
+
"kind": "sheet",
|
|
231
|
+
"title": name,
|
|
232
|
+
"txt": _sheet_to_text(name, rows),
|
|
233
|
+
"tables": [],
|
|
234
|
+
"imgs": [],
|
|
235
|
+
})
|
|
236
|
+
finally:
|
|
237
|
+
wb.close()
|
|
238
|
+
|
|
239
|
+
prepared_images: List[Dict[str, Any]] = []
|
|
240
|
+
try:
|
|
241
|
+
zf = zipfile.ZipFile(path, "r")
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
log.warning("could not open %s as ZIP for image extraction: %s", base_name, exc)
|
|
244
|
+
return document, prepared_images
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
sheet_media_map = _build_sheet_media_map(zf)
|
|
248
|
+
converted: Dict[str, bytes] = {}
|
|
249
|
+
vector_items, wdp_items, seen = [], [], set()
|
|
250
|
+
for media_paths in sheet_media_map.values():
|
|
251
|
+
for media_path in media_paths:
|
|
252
|
+
if media_path in seen:
|
|
253
|
+
continue
|
|
254
|
+
ext = os.path.splitext(media_path)[1].lower()
|
|
255
|
+
try:
|
|
256
|
+
raw = zf.read(media_path)
|
|
257
|
+
except KeyError:
|
|
258
|
+
continue
|
|
259
|
+
if ext in VECTOR_EXTS:
|
|
260
|
+
vector_items.append((media_path, raw))
|
|
261
|
+
seen.add(media_path)
|
|
262
|
+
elif ext in WDP_EXTS:
|
|
263
|
+
wdp_items.append((media_path, raw))
|
|
264
|
+
seen.add(media_path)
|
|
265
|
+
converted = batch_convert_vectors_to_png(vector_items, self.vector_timeout)
|
|
266
|
+
converted.update(decode_wdp_to_png(wdp_items))
|
|
267
|
+
|
|
268
|
+
per_sheet_idx: Dict[int, int] = defaultdict(int)
|
|
269
|
+
processed_media: Set[str] = set()
|
|
270
|
+
for sheet_name, media_paths in sheet_media_map.items():
|
|
271
|
+
pg_num = sheet_index.get(sheet_name, 1)
|
|
272
|
+
for media_path in media_paths:
|
|
273
|
+
# Deduplicate across all sheets (covers converted vectors
|
|
274
|
+
# and any raster referenced by multiple sheets).
|
|
275
|
+
if media_path in processed_media:
|
|
276
|
+
continue
|
|
277
|
+
ext = os.path.splitext(media_path)[1].lower()
|
|
278
|
+
if media_path in converted:
|
|
279
|
+
image_bytes, ext_out = converted[media_path], "png"
|
|
280
|
+
elif ext in RASTER_EXTS:
|
|
281
|
+
try:
|
|
282
|
+
image_bytes = zf.read(media_path)
|
|
283
|
+
except KeyError:
|
|
284
|
+
continue
|
|
285
|
+
ext_out = ext.lstrip(".")
|
|
286
|
+
ext_out = {"tif": "tiff", "jpg": "jpeg"}.get(ext_out, ext_out)
|
|
287
|
+
if ext_out == "png" and not image_bytes[:4].startswith(b"\x89PNG"):
|
|
288
|
+
fixed = ensure_rgb_png(image_bytes)
|
|
289
|
+
if fixed is None:
|
|
290
|
+
continue
|
|
291
|
+
image_bytes = fixed
|
|
292
|
+
else:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
processed_media.add(media_path)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
299
|
+
width, height = image.size
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
log.debug("image decode failed for %s in sheet %r of %s: %s",
|
|
302
|
+
media_path, sheet_name, base_name, exc)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
img_idx = per_sheet_idx[pg_num]
|
|
306
|
+
prepared = image_filter.prepare_image(
|
|
307
|
+
image_bytes=image_bytes,
|
|
308
|
+
ext=ext_out,
|
|
309
|
+
width=width,
|
|
310
|
+
height=height,
|
|
311
|
+
image_id=f"page_{pg_num}_img_{img_idx}",
|
|
312
|
+
page_number=pg_num,
|
|
313
|
+
img_idx=img_idx,
|
|
314
|
+
)
|
|
315
|
+
if prepared is not None:
|
|
316
|
+
prepared_images.append(prepared)
|
|
317
|
+
per_sheet_idx[pg_num] += 1 # only advance for kept images
|
|
318
|
+
finally:
|
|
319
|
+
zf.close()
|
|
320
|
+
|
|
321
|
+
return document, prepared_images
|