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,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
+ ]
@@ -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
+ ]