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.
@@ -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