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
multixtract/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""multixtract — vendor-neutral document extraction for search & RAG.
|
|
2
|
+
|
|
3
|
+
The public API exposes the orchestrator (:class:`Pipeline`), the vendor-neutral
|
|
4
|
+
core functions (:func:`extract_document`, :func:`chunk_document`), the extractor
|
|
5
|
+
registry, and the provider interfaces. Concrete providers live in
|
|
6
|
+
:mod:`multixtract.providers`; format extractors in :mod:`multixtract.extractors`.
|
|
7
|
+
"""
|
|
8
|
+
from .chunking import chunk_document, split_text_into_chunks, table_to_markdown
|
|
9
|
+
from .extraction import extract_document
|
|
10
|
+
from .extractors import (
|
|
11
|
+
ExtractorRegistry,
|
|
12
|
+
default_registry,
|
|
13
|
+
get_extractor,
|
|
14
|
+
register_extractor,
|
|
15
|
+
)
|
|
16
|
+
from .filters import ImageFilterPipeline
|
|
17
|
+
from .interfaces import (
|
|
18
|
+
BlobStore,
|
|
19
|
+
DocumentExtractor,
|
|
20
|
+
Embedder,
|
|
21
|
+
PipelineConfig,
|
|
22
|
+
VisionModel,
|
|
23
|
+
VisionResult,
|
|
24
|
+
)
|
|
25
|
+
from .pipeline import ExtractionResult, Pipeline
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from importlib.metadata import version as _version, PackageNotFoundError as _PackageNotFoundError
|
|
29
|
+
__version__ = _version("multixtract")
|
|
30
|
+
except _PackageNotFoundError:
|
|
31
|
+
__version__ = "0.1.1"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Pipeline",
|
|
35
|
+
"ExtractionResult",
|
|
36
|
+
"PipelineConfig",
|
|
37
|
+
"extract_document",
|
|
38
|
+
"chunk_document",
|
|
39
|
+
"split_text_into_chunks",
|
|
40
|
+
"table_to_markdown",
|
|
41
|
+
"ImageFilterPipeline",
|
|
42
|
+
"DocumentExtractor",
|
|
43
|
+
"ExtractorRegistry",
|
|
44
|
+
"default_registry",
|
|
45
|
+
"get_extractor",
|
|
46
|
+
"register_extractor",
|
|
47
|
+
"VisionModel",
|
|
48
|
+
"Embedder",
|
|
49
|
+
"BlobStore",
|
|
50
|
+
"VisionResult",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
multixtract/chunking.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Document chunking (vendor-neutral).
|
|
2
|
+
|
|
3
|
+
Splits an assembled document into granular chunks for index ingestion / RAG:
|
|
4
|
+
* text — sliding-window splits (~target tokens, ~overlap) at sentence bounds
|
|
5
|
+
* table — one chunk per table, serialized as Markdown
|
|
6
|
+
* image — one chunk per image (caption + OCR + description)
|
|
7
|
+
|
|
8
|
+
Embeddings are attached separately by the pipeline; image chunks can reuse
|
|
9
|
+
embeddings already computed during vision analysis.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("multixtract.chunking")
|
|
18
|
+
|
|
19
|
+
CHUNK_TARGET_TOKENS = 500
|
|
20
|
+
CHUNK_OVERLAP_TOKENS = 50
|
|
21
|
+
CHUNK_MIN_TOKENS = 3 # discard chunks shorter than this (page numbers, stray footers)
|
|
22
|
+
_TOKENS_PER_WORD = 1.3
|
|
23
|
+
|
|
24
|
+
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n{2,}|\n(?=[A-Z0-9])")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate_tokens(text: str) -> int:
|
|
28
|
+
if not text:
|
|
29
|
+
return 0
|
|
30
|
+
return max(1, int(len(text.split()) * _TOKENS_PER_WORD))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clean_table_cell(cell) -> str:
|
|
34
|
+
return str(cell).replace("|", "\\|").replace("\n", " ").strip() if cell is not None else ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_text_into_chunks(
|
|
38
|
+
text: str,
|
|
39
|
+
target_tokens: int = CHUNK_TARGET_TOKENS,
|
|
40
|
+
overlap_tokens: int = CHUNK_OVERLAP_TOKENS,
|
|
41
|
+
) -> List[str]:
|
|
42
|
+
"""Split text into overlapping chunks at sentence boundaries."""
|
|
43
|
+
if not text or not text.strip():
|
|
44
|
+
return []
|
|
45
|
+
sentences = [s.strip() for s in _SENTENCE_SPLIT_RE.split(text.strip()) if s.strip()]
|
|
46
|
+
if not sentences:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
chunks: List[str] = []
|
|
50
|
+
current: List[str] = []
|
|
51
|
+
current_tokens = 0
|
|
52
|
+
# True when current holds only the tail overlap of a large sentence and has
|
|
53
|
+
# no real accumulated sentences yet — must not be flushed as a standalone chunk.
|
|
54
|
+
is_overlap_only = False
|
|
55
|
+
|
|
56
|
+
for sentence in sentences:
|
|
57
|
+
sent_tokens = estimate_tokens(sentence)
|
|
58
|
+
|
|
59
|
+
if sent_tokens >= target_tokens:
|
|
60
|
+
# Large sentence — flush real accumulated content (not a bare tail),
|
|
61
|
+
# emit standalone, then seed next window with its tail so queries
|
|
62
|
+
# spanning the boundary have context. No data is dropped.
|
|
63
|
+
if current and not is_overlap_only:
|
|
64
|
+
chunks.append(" ".join(current))
|
|
65
|
+
chunks.append(sentence)
|
|
66
|
+
tail_words: List[str] = []
|
|
67
|
+
tail_tokens = 0
|
|
68
|
+
for w in reversed(sentence.split()):
|
|
69
|
+
w_tok = estimate_tokens(w)
|
|
70
|
+
if tail_tokens + w_tok > overlap_tokens and tail_words:
|
|
71
|
+
break
|
|
72
|
+
tail_words.insert(0, w)
|
|
73
|
+
tail_tokens += w_tok
|
|
74
|
+
current = [" ".join(tail_words)] if tail_words else []
|
|
75
|
+
current_tokens = tail_tokens
|
|
76
|
+
is_overlap_only = bool(current)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if current_tokens + sent_tokens > target_tokens and current:
|
|
80
|
+
if is_overlap_only:
|
|
81
|
+
# current only holds a carry-over tail, not real content —
|
|
82
|
+
# discard it to avoid emitting a tiny orphan chunk.
|
|
83
|
+
current = []
|
|
84
|
+
current_tokens = 0
|
|
85
|
+
else:
|
|
86
|
+
chunks.append(" ".join(current))
|
|
87
|
+
overlap: List[str] = []
|
|
88
|
+
overlap_count = 0
|
|
89
|
+
for s in reversed(current):
|
|
90
|
+
s_tokens = estimate_tokens(s)
|
|
91
|
+
if overlap_count + s_tokens > overlap_tokens and overlap:
|
|
92
|
+
break
|
|
93
|
+
overlap.insert(0, s)
|
|
94
|
+
overlap_count += s_tokens
|
|
95
|
+
current = overlap
|
|
96
|
+
current_tokens = overlap_count
|
|
97
|
+
|
|
98
|
+
current.append(sentence)
|
|
99
|
+
current_tokens += sent_tokens
|
|
100
|
+
is_overlap_only = False
|
|
101
|
+
|
|
102
|
+
if current and not is_overlap_only:
|
|
103
|
+
chunks.append(" ".join(current))
|
|
104
|
+
return chunks
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_blank_table(table: List[List[Optional[str]]]) -> bool:
|
|
108
|
+
"""Return True if every cell is empty/whitespace/None (chart legend boxes etc.)."""
|
|
109
|
+
return all(not (cell or "").strip() for row in table for cell in row)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def table_to_markdown(table: List[List[Optional[str]]]) -> str:
|
|
113
|
+
if not table or not table[0] or _is_blank_table(table):
|
|
114
|
+
return ""
|
|
115
|
+
header = [_clean_table_cell(c) for c in table[0]]
|
|
116
|
+
lines = [
|
|
117
|
+
"| " + " | ".join(header) + " |",
|
|
118
|
+
"| " + " | ".join("---" for _ in header) + " |",
|
|
119
|
+
]
|
|
120
|
+
for row in table[1:]:
|
|
121
|
+
cells = [_clean_table_cell(c) for c in row]
|
|
122
|
+
while len(cells) < len(header):
|
|
123
|
+
cells.append("")
|
|
124
|
+
if len(cells) > len(header):
|
|
125
|
+
log.debug("table row has %d cells but header has %d; truncating", len(cells), len(header))
|
|
126
|
+
lines.append("| " + " | ".join(cells[:len(header)]) + " |")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_image_content(img_meta: Dict[str, Any]) -> str:
|
|
131
|
+
"""Combine caption, OCR text, and description into a searchable string."""
|
|
132
|
+
parts = []
|
|
133
|
+
if img_meta.get("caption"):
|
|
134
|
+
parts.append(f"Caption: {img_meta['caption']}")
|
|
135
|
+
ocr = img_meta.get("ocr_text")
|
|
136
|
+
if ocr:
|
|
137
|
+
parts.append(f"OCR Text: {ocr}")
|
|
138
|
+
description = img_meta.get("description")
|
|
139
|
+
if description:
|
|
140
|
+
parts.append(f"Description: {description}")
|
|
141
|
+
return "\n\n".join(parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _flush_text_elements(
|
|
145
|
+
text_buffer: List[str],
|
|
146
|
+
base_name: str,
|
|
147
|
+
page_num: int,
|
|
148
|
+
elem_start: int,
|
|
149
|
+
target_tokens: int = CHUNK_TARGET_TOKENS,
|
|
150
|
+
overlap_tokens: int = CHUNK_OVERLAP_TOKENS,
|
|
151
|
+
) -> List[Dict[str, Any]]:
|
|
152
|
+
"""Emit sliding-window text chunks from an accumulated text buffer.
|
|
153
|
+
|
|
154
|
+
Called when a table element or end-of-page is encountered during the
|
|
155
|
+
elements walk, so text before/between/after tables is chunked in order.
|
|
156
|
+
"""
|
|
157
|
+
if not text_buffer:
|
|
158
|
+
return []
|
|
159
|
+
merged = "\n\n".join(text_buffer)
|
|
160
|
+
result = []
|
|
161
|
+
split_index = 0
|
|
162
|
+
for content in split_text_into_chunks(merged, target_tokens, overlap_tokens):
|
|
163
|
+
if estimate_tokens(content) < CHUNK_MIN_TOKENS:
|
|
164
|
+
continue
|
|
165
|
+
result.append({
|
|
166
|
+
"chunk_id": f"{base_name}__p{page_num}_e{elem_start}_txt_{split_index}",
|
|
167
|
+
"chunk_type": "text",
|
|
168
|
+
"pg_num": page_num,
|
|
169
|
+
"chunk_idx": elem_start,
|
|
170
|
+
"content": content,
|
|
171
|
+
"token_cnt": estimate_tokens(content),
|
|
172
|
+
"metadata": {"split_idx": split_index},
|
|
173
|
+
"embedding": None,
|
|
174
|
+
})
|
|
175
|
+
split_index += 1
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def chunk_document(
|
|
180
|
+
document: Dict[str, Any],
|
|
181
|
+
base_name: str,
|
|
182
|
+
image_embeddings: Optional[Dict[str, List[float]]] = None,
|
|
183
|
+
target_tokens: int = CHUNK_TARGET_TOKENS,
|
|
184
|
+
overlap_tokens: int = CHUNK_OVERLAP_TOKENS,
|
|
185
|
+
) -> List[Dict[str, Any]]:
|
|
186
|
+
"""Split an assembled document dict into granular chunks.
|
|
187
|
+
|
|
188
|
+
Pages with an ``elements`` list (PDF new schema) are chunked in document
|
|
189
|
+
order -- text blocks through the sliding-window splitter and tables as
|
|
190
|
+
Markdown, interleaved. Pages using the legacy ``txt``/``tables`` schema
|
|
191
|
+
(docx, pptx, xlsx) are handled by the original path unchanged.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
document: ``{metadata, pgs:[...]}}``
|
|
195
|
+
base_name: Document stem used to build deterministic ``chunk_id``s.
|
|
196
|
+
image_embeddings: Optional ``{img_id: vector}`` to reuse for image
|
|
197
|
+
chunks (avoids re-embedding).
|
|
198
|
+
target_tokens: Target token count per text chunk (default 500).
|
|
199
|
+
overlap_tokens: Overlap token count between chunks (default 50).
|
|
200
|
+
"""
|
|
201
|
+
chunks: List[Dict[str, Any]] = []
|
|
202
|
+
image_embeddings = image_embeddings or {}
|
|
203
|
+
|
|
204
|
+
for page in document.get("pgs", []):
|
|
205
|
+
page_num = page["pg_num"]
|
|
206
|
+
|
|
207
|
+
# ── Elements path (PDF: ordered text + table blocks) ───────────────────
|
|
208
|
+
if "elements" in page:
|
|
209
|
+
text_buffer: List[str] = []
|
|
210
|
+
text_elem_start = 0
|
|
211
|
+
for elem_idx, elem in enumerate(page["elements"]):
|
|
212
|
+
if elem["type"] == "text":
|
|
213
|
+
if not text_buffer:
|
|
214
|
+
text_elem_start = elem_idx
|
|
215
|
+
text_buffer.append(elem["content"])
|
|
216
|
+
elif elem["type"] == "table":
|
|
217
|
+
# Flush preceding text before emitting the table
|
|
218
|
+
chunks.extend(
|
|
219
|
+
_flush_text_elements(text_buffer, base_name, page_num, text_elem_start,
|
|
220
|
+
target_tokens, overlap_tokens)
|
|
221
|
+
)
|
|
222
|
+
text_buffer = []
|
|
223
|
+
content = table_to_markdown(elem["rows"])
|
|
224
|
+
if content:
|
|
225
|
+
rows = elem["rows"]
|
|
226
|
+
chunks.append({
|
|
227
|
+
"chunk_id": f"{base_name}__p{page_num}_e{elem_idx}_tbl",
|
|
228
|
+
"chunk_type": "table",
|
|
229
|
+
"pg_num": page_num,
|
|
230
|
+
"chunk_idx": elem_idx,
|
|
231
|
+
"content": content,
|
|
232
|
+
"token_cnt": estimate_tokens(content),
|
|
233
|
+
"metadata": {
|
|
234
|
+
"num_rows": len(rows),
|
|
235
|
+
"num_col": len(rows[0]) if rows else 0,
|
|
236
|
+
},
|
|
237
|
+
"embedding": None,
|
|
238
|
+
})
|
|
239
|
+
# Flush any remaining text after the last table
|
|
240
|
+
chunks.extend(
|
|
241
|
+
_flush_text_elements(text_buffer, base_name, page_num, text_elem_start,
|
|
242
|
+
target_tokens, overlap_tokens)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# ── Legacy path (docx / pptx / xlsx: txt + tables) ────────────────────
|
|
246
|
+
else:
|
|
247
|
+
text_splits = [
|
|
248
|
+
s for s in split_text_into_chunks(page.get("txt") or "", target_tokens, overlap_tokens)
|
|
249
|
+
if estimate_tokens(s) >= CHUNK_MIN_TOKENS
|
|
250
|
+
]
|
|
251
|
+
for split_index, content in enumerate(text_splits):
|
|
252
|
+
chunks.append({
|
|
253
|
+
"chunk_id": f"{base_name}__p{page_num}_e0_txt_{split_index}",
|
|
254
|
+
"chunk_type": "text",
|
|
255
|
+
"pg_num": page_num,
|
|
256
|
+
"chunk_idx": split_index,
|
|
257
|
+
"content": content,
|
|
258
|
+
"token_cnt": estimate_tokens(content),
|
|
259
|
+
"metadata": {"total_txt_chunks_on_pg": len(text_splits)},
|
|
260
|
+
"embedding": None,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
for table_idx, table in enumerate(page.get("tables") or []):
|
|
265
|
+
content = table_to_markdown(table)
|
|
266
|
+
if not content:
|
|
267
|
+
continue
|
|
268
|
+
chunks.append({
|
|
269
|
+
"chunk_id": f"{base_name}__p{page_num}_e{table_idx}_tbl",
|
|
270
|
+
"chunk_type": "table",
|
|
271
|
+
"pg_num": page_num,
|
|
272
|
+
"chunk_idx": table_idx,
|
|
273
|
+
"content": content,
|
|
274
|
+
"token_cnt": estimate_tokens(content),
|
|
275
|
+
"metadata": {"num_rows": len(table), "num_col": len(table[0]) if table else 0},
|
|
276
|
+
"embedding": None,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
# ── Image chunks (both paths) ────────────────────────────────────
|
|
280
|
+
for img_meta in page.get("imgs") or []:
|
|
281
|
+
content = build_image_content(img_meta)
|
|
282
|
+
if not content:
|
|
283
|
+
continue
|
|
284
|
+
img_index = img_meta.get("img_idx", 0)
|
|
285
|
+
image_id = img_meta.get("img_id", "")
|
|
286
|
+
chunks.append({
|
|
287
|
+
"chunk_id": f"{base_name}__p{page_num}_image_{img_index}",
|
|
288
|
+
"chunk_type": "image",
|
|
289
|
+
"pg_num": page_num,
|
|
290
|
+
"chunk_idx": img_index,
|
|
291
|
+
"content": content,
|
|
292
|
+
"token_cnt": estimate_tokens(content),
|
|
293
|
+
"metadata": {"img_id": image_id, "img_path": img_meta.get("img_path", "")},
|
|
294
|
+
"embedding": image_embeddings.get(image_id),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
return chunks
|
multixtract/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Command-line entry point: ``multixtract``.
|
|
2
|
+
|
|
3
|
+
Runs extraction + chunking on a document and writes JSON to an output folder.
|
|
4
|
+
Supports PDF, DOCX, PPTX, XLSX, CSV, and legacy .doc/.ppt via LibreOffice.
|
|
5
|
+
Vision/embeddings are enabled only when an OpenAI API key is supplied, so the
|
|
6
|
+
bare command works offline with zero cloud setup.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="multixtract",
|
|
21
|
+
description="Extract a document (PDF, DOCX, PPTX, XLSX, CSV, DOC, PPT) to JSON.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("file", help="Path to the input document.")
|
|
24
|
+
parser.add_argument("-o", "--out", default="./output_folder", help="Output folder.")
|
|
25
|
+
parser.add_argument("--openai-key", default=os.getenv("OPENAI_API_KEY", ""),
|
|
26
|
+
help="OpenAI API key. If omitted, runs extraction-only (no vision/embeddings).")
|
|
27
|
+
parser.add_argument("--vision-model", default="gpt-4o")
|
|
28
|
+
parser.add_argument("--embed-model", default="text-embedding-3-large")
|
|
29
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(
|
|
34
|
+
level=logging.INFO if args.verbose else logging.WARNING,
|
|
35
|
+
format="%(levelname)s %(message)s",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .pipeline import Pipeline
|
|
39
|
+
from .providers.storage import LocalDiskStore
|
|
40
|
+
|
|
41
|
+
vision = embedder = None
|
|
42
|
+
if args.openai_key:
|
|
43
|
+
from .providers.openai import OpenAIEmbedder, OpenAIVisionModel
|
|
44
|
+
vision = OpenAIVisionModel(api_key=args.openai_key, model=args.vision_model)
|
|
45
|
+
embedder = OpenAIEmbedder(api_key=args.openai_key, model=args.embed_model)
|
|
46
|
+
|
|
47
|
+
if not os.path.isfile(args.file):
|
|
48
|
+
print(f"error: file not found: {args.file}", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
pipeline = Pipeline(vision=vision, embedder=embedder, store=LocalDiskStore(args.out))
|
|
53
|
+
result = pipeline.process(args.file)
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
print(f"error: file not found: {args.file}", file=sys.stderr)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
except Exception as exc: # noqa: BLE001
|
|
61
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
62
|
+
if args.verbose:
|
|
63
|
+
raise
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
print(f"Extracted {result.base_name}: "
|
|
67
|
+
f"{len(result.document.get('pgs', []))} pages, "
|
|
68
|
+
f"{len(result.chunks)} chunks, "
|
|
69
|
+
f"{len(result.image_index)} images -> {args.out}")
|
|
70
|
+
if result.filter_stats:
|
|
71
|
+
print(f"Image filter stats: {result.filter_stats}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Document extraction dispatcher (vendor-neutral).
|
|
2
|
+
|
|
3
|
+
:func:`extract_document` picks the right :class:`DocumentExtractor` for a file's
|
|
4
|
+
extension via the registry and delegates to it. Built-in extractors cover PDF,
|
|
5
|
+
Word, PowerPoint, and Excel/CSV (see :mod:`multixtract.extractors`); add more by
|
|
6
|
+
implementing :class:`DocumentExtractor` and calling ``register_extractor``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .extractors import get_extractor
|
|
13
|
+
from .extractors.registry import ExtractorRegistry
|
|
14
|
+
from .filters import ImageFilterPipeline
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_document(
|
|
18
|
+
doc_path: str,
|
|
19
|
+
image_filter: Optional[ImageFilterPipeline] = None,
|
|
20
|
+
registry: Optional[ExtractorRegistry] = None,
|
|
21
|
+
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
|
|
22
|
+
"""Extract text, tables, and (filtered) images from a document.
|
|
23
|
+
|
|
24
|
+
The extractor is selected by file extension. Each built-in format works
|
|
25
|
+
once its optional extra is installed (e.g. ``multixtract[pdf]``); calling
|
|
26
|
+
one without its parser raises ``ImportError`` with the right install hint.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
doc_path: Path to the document (any registered extension, e.g. ``.pdf``,
|
|
30
|
+
``.docx``, ``.pptx``, ``.xlsx``, ``.csv``).
|
|
31
|
+
image_filter: Optional :class:`ImageFilterPipeline`. If omitted, the
|
|
32
|
+
extractor creates a default one.
|
|
33
|
+
registry: Optional :class:`ExtractorRegistry`. Defaults to the
|
|
34
|
+
process-wide registry of built-in + user-registered extractors.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
``(document, prepared_images)`` -- see :class:`DocumentExtractor`.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If no extractor is registered for the file's extension.
|
|
41
|
+
ImportError: If the required optional extra for this format is not installed.
|
|
42
|
+
"""
|
|
43
|
+
extractor = registry.get(doc_path) if registry is not None else get_extractor(doc_path)
|
|
44
|
+
return extractor.extract(doc_path, image_filter=image_filter)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pluggable document extractors.
|
|
2
|
+
|
|
3
|
+
Built-in extractors register themselves on the process-wide ``default_registry``
|
|
4
|
+
when this package is imported. To support a new format, implement the
|
|
5
|
+
:class:`~multixtract.interfaces.DocumentExtractor` protocol and call
|
|
6
|
+
:func:`register_extractor`.
|
|
7
|
+
"""
|
|
8
|
+
from .docx import DocxExtractor
|
|
9
|
+
from .pptx import PptxExtractor
|
|
10
|
+
from .excel import ExcelExtractor
|
|
11
|
+
from .image import ImageExtractor
|
|
12
|
+
from .text import TextExtractor
|
|
13
|
+
from .markdown import MarkdownExtractor
|
|
14
|
+
from .html import HtmlExtractor
|
|
15
|
+
from .eml import EmlExtractor
|
|
16
|
+
from .rtf import RtfExtractor
|
|
17
|
+
from .epub import EpubExtractor
|
|
18
|
+
from .legacy import (
|
|
19
|
+
ConvertingExtractor,
|
|
20
|
+
legacy_doc_extractor,
|
|
21
|
+
legacy_ppt_extractor,
|
|
22
|
+
legacy_odt_extractor,
|
|
23
|
+
legacy_odp_extractor,
|
|
24
|
+
legacy_ods_extractor,
|
|
25
|
+
legacy_xls_extractor,
|
|
26
|
+
convert_with_libreoffice,
|
|
27
|
+
find_libreoffice,
|
|
28
|
+
)
|
|
29
|
+
from .pdf import PdfExtractor
|
|
30
|
+
from .registry import (
|
|
31
|
+
ExtractorRegistry,
|
|
32
|
+
default_registry,
|
|
33
|
+
get_extractor,
|
|
34
|
+
register_extractor,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ---- Register built-in extractors -----------------------------------------
|
|
38
|
+
register_extractor(PdfExtractor())
|
|
39
|
+
register_extractor(DocxExtractor()) # native .docx
|
|
40
|
+
register_extractor(PptxExtractor()) # native .pptx
|
|
41
|
+
register_extractor(ExcelExtractor()) # .xlsx / .xlsm / .csv
|
|
42
|
+
register_extractor(ImageExtractor()) # .png / .jpg / .jpeg / .tiff / .tif / .webp / .bmp
|
|
43
|
+
register_extractor(TextExtractor()) # .txt / .log / .conf / .ini / .md
|
|
44
|
+
register_extractor(MarkdownExtractor()) # .md — overrides TextExtractor for .md
|
|
45
|
+
register_extractor(HtmlExtractor()) # .html / .htm
|
|
46
|
+
register_extractor(EmlExtractor()) # .eml
|
|
47
|
+
register_extractor(RtfExtractor()) # .rtf
|
|
48
|
+
register_extractor(EpubExtractor()) # .epub
|
|
49
|
+
# Legacy binaries: convert via LibreOffice, then delegate. They prefer native
|
|
50
|
+
# targets (.docx/.pptx) and fall back to PDF — so .doc now auto-upgrades to
|
|
51
|
+
# DocxExtractor and .ppt to PptxExtractor (native, higher fidelity).
|
|
52
|
+
register_extractor(legacy_doc_extractor)
|
|
53
|
+
register_extractor(legacy_ppt_extractor)
|
|
54
|
+
register_extractor(legacy_odt_extractor) # .odt
|
|
55
|
+
register_extractor(legacy_odp_extractor) # .odp
|
|
56
|
+
register_extractor(legacy_ods_extractor) # .ods
|
|
57
|
+
register_extractor(legacy_xls_extractor) # .xls
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"ExtractorRegistry",
|
|
61
|
+
"default_registry",
|
|
62
|
+
"get_extractor",
|
|
63
|
+
"register_extractor",
|
|
64
|
+
"PdfExtractor",
|
|
65
|
+
"DocxExtractor",
|
|
66
|
+
"PptxExtractor",
|
|
67
|
+
"ExcelExtractor",
|
|
68
|
+
"ImageExtractor",
|
|
69
|
+
"TextExtractor",
|
|
70
|
+
"MarkdownExtractor",
|
|
71
|
+
"HtmlExtractor",
|
|
72
|
+
"EmlExtractor",
|
|
73
|
+
"RtfExtractor",
|
|
74
|
+
"EpubExtractor",
|
|
75
|
+
"ConvertingExtractor",
|
|
76
|
+
"legacy_doc_extractor",
|
|
77
|
+
"legacy_ppt_extractor",
|
|
78
|
+
"legacy_odt_extractor",
|
|
79
|
+
"legacy_odp_extractor",
|
|
80
|
+
"legacy_ods_extractor",
|
|
81
|
+
"legacy_xls_extractor",
|
|
82
|
+
"convert_with_libreoffice",
|
|
83
|
+
"find_libreoffice",
|
|
84
|
+
]
|