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,157 @@
1
+ """Shared image format constants and conversion helpers.
2
+
3
+ Used by the docx, pptx, and excel extractors for handling:
4
+ * raster images (PNG, JPEG, etc.)
5
+ * vector images (EMF, WMF, SVG) — converted to PNG via LibreOffice
6
+ * JPEG XR / HD Photo (.wdp) — converted to PNG via imagecodecs
7
+
8
+ Centralised here to avoid duplication across extractors and to break
9
+ the cross-import coupling (pptx/excel no longer need to import docx).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import logging
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ from typing import Dict, List, Optional, Tuple
20
+
21
+ from .legacy import find_libreoffice
22
+
23
+ log = logging.getLogger("multixtract.extractors")
24
+
25
+ # ── Shared image extension sets ──────────────────────────────────────────────
26
+ RASTER_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"})
27
+ VECTOR_EXTS = frozenset({".emf", ".wmf", ".svg"})
28
+ WDP_EXTS = frozenset({".wdp"})
29
+ IMAGE_EXTS = RASTER_EXTS | VECTOR_EXTS | WDP_EXTS | frozenset({".tmp", ".bin", ".mpo"})
30
+
31
+
32
+ # ── Image conversion helpers ─────────────────────────────────────────────────
33
+
34
+ def ensure_rgb_png(image_bytes: bytes) -> Optional[bytes]:
35
+ """Re-encode misnamed/palette images to a clean RGB PNG. Returns None on failure."""
36
+ try:
37
+ from PIL import Image
38
+ img = Image.open(io.BytesIO(image_bytes))
39
+ try:
40
+ if img.mode != "RGB":
41
+ orig = img
42
+ img = orig.convert("RGB")
43
+ orig.close()
44
+ png_buffer = io.BytesIO()
45
+ img.save(png_buffer, format="PNG")
46
+ finally:
47
+ img.close()
48
+ return png_buffer.getvalue()
49
+ except Exception:
50
+ return None
51
+
52
+
53
+ def batch_convert_vectors_to_png(
54
+ vector_items: List[Tuple[str, bytes]],
55
+ timeout: int = 120,
56
+ ) -> Dict[str, bytes]:
57
+ """Convert all EMF/WMF/SVG images to PNG in ONE LibreOffice batch call.
58
+
59
+ Args:
60
+ vector_items: List of (media_path, raw_bytes) tuples.
61
+ timeout: LibreOffice subprocess timeout in seconds.
62
+
63
+ Returns:
64
+ Dict mapping media_path -> PNG bytes for successfully converted images.
65
+ """
66
+ if not vector_items:
67
+ return {}
68
+ soffice = find_libreoffice()
69
+ if soffice is None:
70
+ log.debug("LibreOffice not found; skipping %d vector image(s)", len(vector_items))
71
+ return {}
72
+
73
+ results: Dict[str, bytes] = {}
74
+ temp_dir = tempfile.mkdtemp(prefix="multixtract_vec_")
75
+ try:
76
+ inputs = []
77
+ for idx, (media_path, raw) in enumerate(vector_items):
78
+ # Prefix with the item index so two paths that differ only by "/" vs "_"
79
+ # (e.g. "ppt/media/img.emf" and "ppt_media_img.emf") never collide.
80
+ basename = os.path.basename(media_path)
81
+ safe = f"{idx}__{basename}"
82
+ input_path = os.path.join(temp_dir, safe)
83
+ with open(input_path, "wb") as fh:
84
+ fh.write(raw)
85
+ inputs.append((media_path, safe))
86
+
87
+ cmd = [soffice, "--headless", "--convert-to", "png", "--outdir", temp_dir] + [
88
+ os.path.join(temp_dir, safe) for _, safe in inputs
89
+ ]
90
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
91
+ if proc.returncode != 0:
92
+ log.warning(
93
+ "LibreOffice vector conversion failed (exit %d) for %d image(s) — "
94
+ "stderr: %s",
95
+ proc.returncode, len(vector_items), proc.stderr[:300],
96
+ )
97
+ return {}
98
+
99
+ for media_path, safe in inputs:
100
+ png = os.path.join(temp_dir, f"{os.path.splitext(safe)[0]}.png")
101
+ if os.path.exists(png):
102
+ with open(png, "rb") as fh:
103
+ data = fh.read()
104
+ if not data[:4].startswith(b"\x89PNG") or (len(data) > 25 and data[25] == 3):
105
+ data = ensure_rgb_png(data) or data
106
+ results[media_path] = data
107
+
108
+ except subprocess.TimeoutExpired:
109
+ log.warning(
110
+ "LibreOffice timed out after %ds converting %d vector image(s) — "
111
+ "all vector images in this document will be skipped",
112
+ timeout, len(vector_items),
113
+ )
114
+ except Exception as exc:
115
+ log.warning("LibreOffice batch conversion failed (%d vector image(s)): %s",
116
+ len(vector_items), exc)
117
+ finally:
118
+ shutil.rmtree(temp_dir, ignore_errors=True)
119
+ return results
120
+
121
+
122
+ def decode_wdp_to_png(wdp_items: List[Tuple[str, bytes]]) -> Dict[str, bytes]:
123
+ """Decode JPEG XR / HD Photo (.wdp) files to PNG via imagecodecs.
124
+
125
+ Args:
126
+ wdp_items: List of (media_path, raw_bytes) tuples.
127
+
128
+ Returns:
129
+ Dict mapping media_path -> PNG bytes for successfully decoded images.
130
+ Returns empty dict if imagecodecs is not installed.
131
+ """
132
+ if not wdp_items:
133
+ return {}
134
+ try:
135
+ import imagecodecs
136
+ from PIL import Image
137
+ except ImportError:
138
+ return {}
139
+
140
+ out: Dict[str, bytes] = {}
141
+ for media_path, raw in wdp_items:
142
+ try:
143
+ arr = imagecodecs.jpegxr_decode(raw)
144
+ img = Image.fromarray(arr)
145
+ try:
146
+ if img.mode not in ("RGB", "RGBA"):
147
+ orig = img
148
+ img = orig.convert("RGB")
149
+ orig.close()
150
+ png_buffer = io.BytesIO()
151
+ img.save(png_buffer, format="PNG")
152
+ finally:
153
+ img.close()
154
+ out[media_path] = png_buffer.getvalue()
155
+ except Exception as exc:
156
+ log.debug("WDP decode failed for %s: %s", media_path, exc)
157
+ return out
@@ -0,0 +1,375 @@
1
+ """Native Word (.docx) extractor.
2
+
3
+ Ported from the ZF Word + GPT-4o Vision pipeline. Extracts, with python-docx and
4
+ OOXML XML parsing:
5
+ * core-properties metadata (author, dates, title, subject, counts)
6
+ * logical pages split on Word page breaks (lastRenderedPageBreak / hard breaks)
7
+ * paragraph text, tables, and hyperlinks per page
8
+ * images from the ``word/media/`` ZIP archive, with image->page mapping
9
+
10
+ Vector images (EMF/WMF) are converted to PNG via a single headless LibreOffice
11
+ batch call; JPEG XR (.wdp) via imagecodecs when available. Images are filtered
12
+ through the shared :class:`ImageFilterPipeline` and returned as ``prepared_images``
13
+ for downstream vision analysis — the pipeline fills each page's ``imgs`` list.
14
+
15
+ Requires the ``[docx]`` extra (python-docx). EMF/WMF need system LibreOffice;
16
+ .wdp needs the optional ``imagecodecs`` package. Both degrade gracefully.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import logging
22
+ import os
23
+ import zipfile
24
+ from typing import Any, Dict, List, Optional, Tuple
25
+
26
+ from ..filters import ImageFilterPipeline
27
+ from ._image_utils import (
28
+ IMAGE_EXTS,
29
+ VECTOR_EXTS,
30
+ WDP_EXTS,
31
+ batch_convert_vectors_to_png,
32
+ decode_wdp_to_png,
33
+ ensure_rgb_png,
34
+ )
35
+
36
+ log = logging.getLogger("multixtract.extractors.docx")
37
+
38
+ # ── OOXML namespaces ──
39
+ _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
40
+ _R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
41
+ _A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
42
+ _W_TAG_P = f"{{{_W_NS}}}p"
43
+ _W_TAG_TBL = f"{{{_W_NS}}}tbl"
44
+ _W_TAG_T = f"{{{_W_NS}}}t"
45
+ _W_TAG_LRPB = f"{{{_W_NS}}}lastRenderedPageBreak"
46
+ _W_TAG_BR = f"{{{_W_NS}}}br"
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # XML / relationship helpers
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _build_doc_rels(doc) -> Dict[str, str]:
54
+ """Map relationship id -> target (URL or part path)."""
55
+ rels: Dict[str, str] = {}
56
+ try:
57
+ rel_values = list(doc.part.rels.values())
58
+ except Exception:
59
+ return rels
60
+ for rel in rel_values:
61
+ try:
62
+ rels[rel.rId] = rel._target
63
+ except Exception:
64
+ pass
65
+ return rels
66
+
67
+
68
+ def _build_image_rid_to_media(doc) -> Dict[str, str]:
69
+ """Map relationship id -> media path (e.g. 'word/media/image1.png')."""
70
+ out: Dict[str, str] = {}
71
+ try:
72
+ rel_values = list(doc.part.rels.values())
73
+ except Exception:
74
+ return out
75
+ for rel in rel_values:
76
+ try:
77
+ target = rel._target
78
+ partname = getattr(target, "partname", None)
79
+ if partname is not None and "/media/" in str(partname):
80
+ out[rel.rId] = str(partname).lstrip("/")
81
+ elif isinstance(target, str) and "media/" in target:
82
+ out[rel.rId] = target if target.startswith("word/") else f"word/{target}"
83
+ except Exception:
84
+ pass
85
+ return out
86
+
87
+
88
+ def _hyperlinks_in_paragraph(para_el, doc_rels: Dict[str, str]) -> List[str]:
89
+ links: List[str] = []
90
+ for hl in para_el.findall(f".//{{{_W_NS}}}hyperlink"):
91
+ rid = hl.get(f"{{{_R_NS}}}id")
92
+ url = doc_rels.get(rid, "") if rid else ""
93
+ if url and url.startswith(("http://", "https://", "ftp://")):
94
+ links.append(url)
95
+ return links
96
+
97
+
98
+ def _image_rids_in_paragraph(para_el) -> List[str]:
99
+ rids: List[str] = []
100
+ for blip in para_el.iter(f"{{{_A_NS}}}blip"):
101
+ rid = blip.get(f"{{{_R_NS}}}embed")
102
+ if rid:
103
+ rids.append(rid)
104
+ return rids
105
+
106
+
107
+ def _split_para_at_lrpb(para_el) -> Tuple[str, str]:
108
+ """Split paragraph text at the first lastRenderedPageBreak.
109
+
110
+ Walks the paragraph's descendants in document order, accumulating <w:t>
111
+ text into a 'before' bucket until the first <w:lastRenderedPageBreak> is
112
+ encountered (which may be a direct child or nested inside a <w:r>), then
113
+ switches to an 'after' bucket.
114
+
115
+ Returns (before_text, after_text). If no lrpb is present, all text goes
116
+ into before_text and after_text is empty.
117
+ """
118
+ before: List[str] = []
119
+ after: List[str] = []
120
+ seen_break = False
121
+ for el in para_el.iter():
122
+ if el.tag == _W_TAG_LRPB:
123
+ seen_break = True
124
+ elif el.tag == _W_TAG_T and el.text:
125
+ (after if seen_break else before).append(el.text)
126
+ return "".join(before).strip(), "".join(after).strip()
127
+
128
+
129
+ def _build_pages_from_body(
130
+ doc,
131
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, int], Dict[str, int]]:
132
+ """Split the document body into logical pages on Word page breaks.
133
+
134
+ Returns (pages, media_to_page, media_ref_counts) where:
135
+ - pages: each have paragraphs/tables/hyperlinks
136
+ - media_to_page: maps media path to its 1-based page number (last occurrence)
137
+ - media_ref_counts: how many times each media path was referenced in the body
138
+ """
139
+ doc_rels = _build_doc_rels(doc)
140
+ rid_to_media = _build_image_rid_to_media(doc)
141
+ body = doc.element.body
142
+ has_lrpb = len(body.findall(f".//{{{_W_NS}}}lastRenderedPageBreak")) > 0
143
+
144
+ pages: List[Dict[str, Any]] = []
145
+ current: Dict[str, Any] = {"paragraphs": [], "tables": [], "hyperlinks": []}
146
+ media_to_page: Dict[str, int] = {}
147
+ media_ref_counts: Dict[str, int] = {}
148
+
149
+ def _finalize():
150
+ nonlocal current
151
+ if current["paragraphs"] or current["tables"]:
152
+ pages.append(current)
153
+ current = {"paragraphs": [], "tables": [], "hyperlinks": []}
154
+
155
+ for child in body:
156
+ tag = child.tag
157
+ if tag == _W_TAG_P:
158
+ lrpb_els = child.findall(f".//{{{_W_NS}}}lastRenderedPageBreak") if has_lrpb else []
159
+ if lrpb_els:
160
+ # Split text at the break point so pre-break text stays on the
161
+ # current page and post-break text goes onto the new page.
162
+ before_text, after_text = _split_para_at_lrpb(child)
163
+ if before_text:
164
+ current["paragraphs"].append(before_text)
165
+ _finalize()
166
+ text = after_text
167
+ else:
168
+ text = "".join(t.text for t in child.iter(_W_TAG_T) if t.text).strip()
169
+ if text:
170
+ current["paragraphs"].append(text)
171
+ links = _hyperlinks_in_paragraph(child, doc_rels)
172
+ if links:
173
+ current["hyperlinks"].extend(links)
174
+ pg_num = len(pages) + 1
175
+ for rid in _image_rids_in_paragraph(child):
176
+ media = rid_to_media.get(rid, "")
177
+ if media:
178
+ media_to_page[media] = pg_num
179
+ media_ref_counts[media] = media_ref_counts.get(media, 0) + 1
180
+ has_hard_break = any(
181
+ br.get(f"{{{_W_NS}}}type") == "page"
182
+ for br in child.iter(_W_TAG_BR)
183
+ )
184
+ if has_hard_break:
185
+ _finalize()
186
+ elif tag == _W_TAG_TBL:
187
+ if has_lrpb and child.findall(f".//{{{_W_NS}}}lastRenderedPageBreak"):
188
+ if current["paragraphs"] or current["tables"]:
189
+ _finalize()
190
+ try:
191
+ table: List[List[str]] = []
192
+ for tr in child.findall(f".//{{{_W_NS}}}tr"):
193
+ row = [
194
+ "".join(t.text for t in tc.iter(f"{{{_W_NS}}}t") if t.text).strip()
195
+ for tc in tr.findall(f".//{{{_W_NS}}}tc")
196
+ ]
197
+ if row:
198
+ table.append(row)
199
+ if table and any(any(c for c in r) for r in table):
200
+ current["tables"].append(table)
201
+ except Exception as exc:
202
+ log.debug("table parse skipped (body traversal): %s", exc)
203
+ # Map images embedded inside table cells to the current page.
204
+ # Top-level <w:p> images are handled above; cell paragraphs are
205
+ # nested inside <w:tc> and were previously never walked.
206
+ pg_num = len(pages) + 1
207
+ for para_el in child.iter(_W_TAG_P):
208
+ for rid in _image_rids_in_paragraph(para_el):
209
+ media = rid_to_media.get(rid, "")
210
+ if media:
211
+ media_to_page[media] = pg_num
212
+ media_ref_counts[media] = media_ref_counts.get(media, 0) + 1
213
+
214
+ if current["paragraphs"] or current["tables"]:
215
+ pages.append(current)
216
+ return pages, media_to_page, media_ref_counts
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Extractor
221
+ # ---------------------------------------------------------------------------
222
+
223
+ class DocxExtractor:
224
+ """DocumentExtractor for ``.docx`` (native python-docx extraction)."""
225
+
226
+ extensions: Tuple[str, ...] = (".docx",)
227
+
228
+ def __init__(self, vector_timeout: int = 120) -> None:
229
+ self.vector_timeout = vector_timeout
230
+
231
+ def extract(
232
+ self,
233
+ path: str,
234
+ image_filter: Optional[ImageFilterPipeline] = None,
235
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
236
+ try:
237
+ from docx import Document as DocxDocument
238
+ except ImportError as e:
239
+ raise ImportError(
240
+ "Word support requires python-docx: pip install 'multixtract[docx]'"
241
+ ) from e
242
+ from PIL import Image
243
+
244
+ if image_filter is None:
245
+ image_filter = ImageFilterPipeline()
246
+ image_filter.reset()
247
+
248
+ base_name = os.path.splitext(os.path.basename(path))[0]
249
+ empty: Dict[str, Any] = {"_base_name": base_name, "metadata": {}, "pgs": []}
250
+ try:
251
+ doc = DocxDocument(path)
252
+ cp = doc.core_properties
253
+ pages, media_to_page, media_ref_counts = _build_pages_from_body(doc)
254
+
255
+ document: Dict[str, Any] = {
256
+ "metadata": {
257
+ "author": cp.author,
258
+ "created": cp.created.isoformat() if cp.created else None,
259
+ "modified": cp.modified.isoformat() if cp.modified else None,
260
+ "last_modified_by": cp.last_modified_by,
261
+ "title": cp.title,
262
+ "subject": cp.subject,
263
+ "paragraph_count": sum(len(p["paragraphs"]) for p in pages),
264
+ "table_count": sum(len(p["tables"]) for p in pages),
265
+ "page_count": len(pages),
266
+ },
267
+ "_base_name": base_name,
268
+ "pgs": [],
269
+ }
270
+ for pg_idx, page in enumerate(pages):
271
+ document["pgs"].append({
272
+ "pg_num": pg_idx + 1,
273
+ "kind": "page",
274
+ "txt": "\n".join(page["paragraphs"]),
275
+ "tables": page["tables"],
276
+ "hyperlinks": list(dict.fromkeys(page["hyperlinks"])),
277
+ "imgs": [],
278
+ })
279
+ # Guarantee at least one page so images have somewhere to map.
280
+ if not document["pgs"]:
281
+ document["pgs"].append({
282
+ "pg_num": 1, "kind": "page", "txt": "",
283
+ "tables": [], "hyperlinks": [], "imgs": [],
284
+ })
285
+
286
+ prepared_images: List[Dict[str, Any]] = []
287
+ try:
288
+ zf = zipfile.ZipFile(path, "r")
289
+ except Exception as exc:
290
+ log.warning("could not open %s as ZIP for image extraction: %s", base_name, exc)
291
+ return document, prepared_images
292
+
293
+ try:
294
+ media_files = [n for n in zf.namelist() if n.startswith("word/media/")]
295
+
296
+ # Pre-convert vector (EMF/WMF) and WDP images.
297
+ vector_items, wdp_items = [], []
298
+ for media_path in media_files:
299
+ ext = os.path.splitext(media_path)[1].lower()
300
+ try:
301
+ if ext in VECTOR_EXTS:
302
+ vector_items.append((media_path, zf.read(media_path)))
303
+ elif ext in WDP_EXTS:
304
+ wdp_items.append((media_path, zf.read(media_path)))
305
+ except KeyError:
306
+ pass
307
+ converted = batch_convert_vectors_to_png(vector_items, self.vector_timeout)
308
+ converted.update(decode_wdp_to_png(wdp_items))
309
+
310
+ page_img_idx: Dict[int, int] = {}
311
+ for media_path in media_files:
312
+ ext = os.path.splitext(media_path)[1].lower()
313
+ if ext not in IMAGE_EXTS:
314
+ continue
315
+
316
+ if media_path in converted:
317
+ image_bytes, ext_out = converted[media_path], "png"
318
+ elif ext in VECTOR_EXTS or ext in WDP_EXTS:
319
+ continue # conversion failed; skip
320
+ else:
321
+ try:
322
+ image_bytes = zf.read(media_path)
323
+ except KeyError:
324
+ continue
325
+ if ext == ".tmp":
326
+ if image_bytes[:4] == b"\x89PNG":
327
+ ext_out = "png"
328
+ elif image_bytes[:2] == b"\xff\xd8":
329
+ ext_out = "jpeg"
330
+ else:
331
+ continue
332
+ else:
333
+ ext_out = ext.lstrip(".")
334
+ ext_out = {"tif": "tiff", "jpg": "jpeg"}.get(ext_out, ext_out)
335
+ if ext_out == "png" and not image_bytes[:4].startswith(b"\x89PNG"):
336
+ fixed = ensure_rgb_png(image_bytes)
337
+ if fixed is None:
338
+ continue
339
+ image_bytes = fixed
340
+
341
+ # Dimensions for filtering.
342
+ try:
343
+ with Image.open(io.BytesIO(image_bytes)) as image:
344
+ width, height = image.size
345
+ except Exception as exc:
346
+ log.debug("image decode failed for %s in %s: %s", media_path, base_name, exc)
347
+ continue
348
+
349
+ pg_num = media_to_page.get(media_path, 1)
350
+ image_index = page_img_idx.get(pg_num, 0)
351
+ page_img_idx[pg_num] = image_index + 1
352
+
353
+ prepared = image_filter.prepare_image(
354
+ image_bytes=image_bytes,
355
+ ext=ext_out,
356
+ width=width,
357
+ height=height,
358
+ image_id=f"page_{pg_num}_img_{image_index}",
359
+ page_number=pg_num,
360
+ img_idx=image_index,
361
+ )
362
+ if prepared is not None:
363
+ prepared_images.append(prepared)
364
+ # Count extra references to this media file as duplicates.
365
+ for _ in range(media_ref_counts.get(media_path, 1) - 1):
366
+ image_filter.note_duplicate()
367
+ finally:
368
+ zf.close()
369
+
370
+ return document, prepared_images
371
+ except ImportError:
372
+ raise
373
+ except Exception:
374
+ log.warning("DocxExtractor failed for %s", path, exc_info=True)
375
+ return empty, []