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