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,72 @@
1
+ """Extractor registry — maps file extensions to DocumentExtractor factories.
2
+
3
+ The registry lets the pipeline pick the right extractor automatically based on
4
+ file type, and lets users register their own extractors for new formats::
5
+
6
+ from multixtract.extractors import register_extractor
7
+ from multixtract.interfaces import DocumentExtractor
8
+
9
+ class MarkdownExtractor:
10
+ extensions = (".md",)
11
+ def extract(self, path): ...
12
+
13
+ register_extractor(MarkdownExtractor())
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from typing import Dict, Iterable
19
+
20
+ from ..interfaces import DocumentExtractor
21
+
22
+
23
+ class ExtractorRegistry:
24
+ """Dispatches a file path to the DocumentExtractor for its extension."""
25
+
26
+ def __init__(self) -> None:
27
+ self._by_ext: Dict[str, DocumentExtractor] = {}
28
+
29
+ def register(self, extractor: DocumentExtractor, extensions: Iterable[str] | None = None) -> DocumentExtractor:
30
+ """Register *extractor* for the given *extensions* (defaults to the
31
+ extractor's own ``extensions`` attribute). Later registrations for the
32
+ same extension win, so users can override built-ins."""
33
+ exts = extensions if extensions is not None else getattr(extractor, "extensions", ())
34
+ for ext in exts:
35
+ self._by_ext[_norm(ext)] = extractor
36
+ return extractor
37
+
38
+ def get(self, path: str) -> DocumentExtractor:
39
+ """Return the extractor for *path*'s extension, or raise."""
40
+ ext = _norm(os.path.splitext(path)[1])
41
+ extractor = self._by_ext.get(ext)
42
+ if extractor is None:
43
+ raise ValueError(
44
+ f"No extractor registered for '{ext or path}'. "
45
+ f"Supported: {sorted(self._by_ext)}. "
46
+ f"Register one with register_extractor()."
47
+ )
48
+ return extractor
49
+
50
+ @property
51
+ def supported_extensions(self):
52
+ return sorted(self._by_ext)
53
+
54
+
55
+ def _norm(ext: str) -> str:
56
+ ext = ext.lower().strip()
57
+ return ext if ext.startswith(".") or ext == "" else f".{ext}"
58
+
59
+
60
+ # Process-wide default registry. Built-in extractors register themselves on
61
+ # import of multixtract.extractors.
62
+ default_registry = ExtractorRegistry()
63
+
64
+
65
+ def register_extractor(extractor: DocumentExtractor, extensions: Iterable[str] | None = None) -> DocumentExtractor:
66
+ """Register *extractor* on the process-wide default registry."""
67
+ return default_registry.register(extractor, extensions)
68
+
69
+
70
+ def get_extractor(path: str) -> DocumentExtractor:
71
+ """Look up the default-registry extractor for *path*."""
72
+ return default_registry.get(path)
@@ -0,0 +1,54 @@
1
+ """RTF extractor (.rtf).
2
+
3
+ Requires striprtf (optional extra [rtf]).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ log = logging.getLogger("multixtract")
12
+
13
+
14
+ class RtfExtractor:
15
+ """DocumentExtractor for RTF files."""
16
+
17
+ extensions: Tuple[str, ...] = (".rtf",)
18
+
19
+ def extract(
20
+ self,
21
+ path: str,
22
+ image_filter: Optional[Any] = None,
23
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
24
+ base_name = os.path.splitext(os.path.basename(path))[0]
25
+ empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
26
+ try:
27
+ from striprtf.striprtf import rtf_to_text
28
+ except ImportError as exc:
29
+ raise ImportError(
30
+ "RTF support requires striprtf: pip install 'multixtract[rtf]'"
31
+ ) from exc
32
+ try:
33
+ with open(path, encoding="utf-8", errors="replace") as fh:
34
+ raw = fh.read()
35
+
36
+ txt = rtf_to_text(raw).strip()
37
+
38
+ document = {
39
+ "_base_name": base_name,
40
+ "metadata": {
41
+ "page_count": 1,
42
+ "format": "rtf",
43
+ "char_count": len(txt),
44
+ },
45
+ "pgs": [
46
+ {"pg_num": 1, "txt": txt, "tables": [], "imgs": []}
47
+ ],
48
+ }
49
+ return document, []
50
+ except ImportError:
51
+ raise
52
+ except Exception:
53
+ log.warning("RtfExtractor failed for %s", path, exc_info=True)
54
+ return empty, []
@@ -0,0 +1,50 @@
1
+ """Plain-text extractor (.txt / .log / .conf / .ini / .md).
2
+
3
+ The entire file becomes a single page. A dedicated MarkdownExtractor
4
+ registered later will override the .md extension with richer handling.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ log = logging.getLogger("multixtract")
13
+
14
+
15
+ class TextExtractor:
16
+ """DocumentExtractor for plain-text files."""
17
+
18
+ extensions: Tuple[str, ...] = (".txt", ".log", ".conf", ".ini", ".md")
19
+
20
+ def extract(
21
+ self,
22
+ path: str,
23
+ image_filter: Optional[Any] = None,
24
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
25
+ base_name = os.path.splitext(os.path.basename(path))[0]
26
+ fmt = os.path.splitext(path)[1].lstrip(".").lower() or "txt"
27
+ empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
28
+ try:
29
+ try:
30
+ with open(path, encoding="utf-8") as fh:
31
+ text = fh.read()
32
+ except UnicodeDecodeError:
33
+ with open(path, encoding="latin-1", errors="replace") as fh:
34
+ text = fh.read()
35
+
36
+ document = {
37
+ "_base_name": base_name,
38
+ "metadata": {
39
+ "page_count": 1,
40
+ "format": fmt,
41
+ "char_count": len(text),
42
+ },
43
+ "pgs": [
44
+ {"pg_num": 1, "txt": text, "tables": [], "imgs": []}
45
+ ],
46
+ }
47
+ return document, []
48
+ except Exception:
49
+ log.warning("TextExtractor failed for %s", path, exc_info=True)
50
+ return empty, []
multixtract/filters.py ADDED
@@ -0,0 +1,181 @@
1
+ """Image filtering pipeline (vendor-neutral).
2
+
3
+ Decides which extracted images are worth analyzing. Depends only on Pillow
4
+ and ImageHash — no cloud SDKs.
5
+
6
+ Filter order (correctness + performance):
7
+ 1. Absolute minimum — max(w,h) < 20 -> instant reject (no PIL decode)
8
+ 2. PIL decode — single decode shared by later stages
9
+ 3. pHash compute — shared by logo detection
10
+ 4. Reference-logo — highest priority so small logos are tagged, not kept
11
+ 5. Dimension filter — two-threshold (max & min side)
12
+ 6. Content quality — solid_color, tiny_icon
13
+
14
+ Cross-page dedup is handled by the extractor via xref tracking, not here.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import os
20
+ from collections import defaultdict
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ import imagehash
24
+ from PIL import Image
25
+
26
+ _ABSOLUTE_MIN_DIM = 20
27
+
28
+
29
+ class ImageFilterPipeline:
30
+ """Stateful image filtering and preparation.
31
+
32
+ One instance per process; call :meth:`reset` at the start of each document
33
+ to clear per-document filter statistics.
34
+ """
35
+
36
+ SOLID_RANGE_MAX = 35
37
+ ICON_MAX_DIM = 200
38
+ ICON_MAX_COLORS = 8
39
+ SAMPLE_SIZE = 64
40
+ COLOR_SAMPLE_MAX = 256
41
+ LOGO_PHASH_THRESHOLD = 60
42
+ LOGO_ASPECT_RANGE = (0.2, 5.0)
43
+
44
+ def __init__(
45
+ self,
46
+ min_image_size: int = 100,
47
+ min_image_size_minor: int = 75,
48
+ reference_img_dir: str = "",
49
+ ) -> None:
50
+ self.min_image_size = min_image_size
51
+ self.min_image_size_minor = min_image_size_minor
52
+ self._reference_hashes: List[Tuple[imagehash.ImageHash, str]] = []
53
+ if reference_img_dir:
54
+ self._load_reference_images(reference_img_dir)
55
+ self.reset()
56
+
57
+ # ---- reference logos -------------------------------------------------
58
+
59
+ def _load_reference_images(self, ref_dir: str) -> None:
60
+ if not os.path.isdir(ref_dir):
61
+ return
62
+ exts = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff"}
63
+ for fname in sorted(os.listdir(ref_dir)):
64
+ if os.path.splitext(fname)[1].lower() not in exts:
65
+ continue
66
+ try:
67
+ img = Image.open(os.path.join(ref_dir, fname)).convert("RGB")
68
+ try:
69
+ self._reference_hashes.append((imagehash.phash(img, hash_size=16), fname))
70
+ finally:
71
+ img.close()
72
+ except Exception:
73
+ pass
74
+
75
+ def _is_reference_logo(self, phash, width: int, height: int) -> Tuple[bool, str]:
76
+ if not self._reference_hashes:
77
+ return False, ""
78
+ aspect = width / max(height, 1)
79
+ aspect_min, aspect_max = self.LOGO_ASPECT_RANGE
80
+ if not (aspect_min <= aspect <= aspect_max):
81
+ return False, ""
82
+ best_dist, best_ref = min(
83
+ ((phash - rh, rn) for rh, rn in self._reference_hashes),
84
+ key=lambda x: x[0],
85
+ )
86
+ return (best_dist <= self.LOGO_PHASH_THRESHOLD, best_ref)
87
+
88
+ # ---- per-document state ---------------------------------------------
89
+
90
+ def reset(self) -> None:
91
+ """Reset per-document filter statistics."""
92
+ self._filter_stats: Dict[str, int] = defaultdict(int)
93
+
94
+ @property
95
+ def filter_stats(self) -> Dict[str, int]:
96
+ return dict(self._filter_stats)
97
+
98
+ def note_duplicate(self) -> None:
99
+ """Record a cross-page duplicate (called by the extractor)."""
100
+ self._filter_stats["duplicate"] += 1
101
+
102
+ # ---- content quality -------------------------------------------------
103
+
104
+ def _is_low_value(self, small, small_nearest) -> Tuple[bool, str]:
105
+ if max(channel_max - channel_min for channel_min, channel_max in small.getextrema()) < self.SOLID_RANGE_MAX:
106
+ return True, "solid_color"
107
+ if small_nearest is not None:
108
+ try:
109
+ ic = small_nearest.getcolors(maxcolors=self.COLOR_SAMPLE_MAX)
110
+ if ic and len(ic) <= self.ICON_MAX_COLORS:
111
+ return True, "tiny_icon"
112
+ except Exception:
113
+ pass
114
+ return False, ""
115
+
116
+ # ---- main entry point ------------------------------------------------
117
+
118
+ def prepare_image(
119
+ self,
120
+ image_bytes: bytes,
121
+ ext: str,
122
+ width: int,
123
+ height: int,
124
+ image_id: str,
125
+ page_number: int,
126
+ img_idx: int,
127
+ ) -> Optional[Dict[str, Any]]:
128
+ """Apply all filters to one image. Returns a metadata dict for images
129
+ that pass, or ``None`` for images that are filtered out. The returned
130
+ dict carries ``image_bytes`` so the caller can run vision on it.
131
+ """
132
+ if max(width, height) < _ABSOLUTE_MIN_DIM:
133
+ self._filter_stats["dimension"] += 1
134
+ return None
135
+
136
+ try:
137
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
138
+ except Exception:
139
+ self._filter_stats["decode_error"] += 1
140
+ return None
141
+
142
+ try:
143
+ phash = imagehash.phash(img, hash_size=16)
144
+
145
+ is_logo, _ = self._is_reference_logo(phash, width, height)
146
+ if is_logo:
147
+ self._filter_stats["ref_logo"] += 1
148
+ return None
149
+
150
+ if max(width, height) < self.min_image_size or min(width, height) < self.min_image_size_minor:
151
+ self._filter_stats["dimension"] += 1
152
+ return None
153
+
154
+ sample_size = self.SAMPLE_SIZE
155
+ small = img.resize((sample_size, sample_size), Image.Resampling.NEAREST)
156
+ # Reuse `small` for the icon colour check — same dimensions, no second decode.
157
+ small_nearest = small if max(width, height) < self.ICON_MAX_DIM else None
158
+ try:
159
+ skip, reason = self._is_low_value(small, small_nearest)
160
+ except Exception:
161
+ skip, reason = False, ""
162
+ finally:
163
+ small.close()
164
+ # small_nearest is either None or the same object as small; don't double-close.
165
+ if skip:
166
+ self._filter_stats[reason] += 1
167
+ return None
168
+ finally:
169
+ img.close()
170
+
171
+ self._filter_stats["kept"] += 1
172
+ return {
173
+ "image_id": image_id,
174
+ "page_number": page_number,
175
+ "img_idx": img_idx,
176
+ "image_bytes": image_bytes,
177
+ "ext": ext,
178
+ "width": width,
179
+ "height": height,
180
+ "img_path": f"pg{page_number}_img{img_idx}.{ext}",
181
+ }
@@ -0,0 +1,151 @@
1
+ """Vendor-neutral provider interfaces.
2
+
3
+ The core pipeline depends ONLY on these contracts — never on a concrete
4
+ vendor SDK. Swap OpenAI for a local model, Azure Blob for S3 or local disk,
5
+ by providing a class that satisfies the relevant Protocol.
6
+
7
+ Protocols use structural typing: any object with the right methods works,
8
+ no explicit subclassing required.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
14
+
15
+
16
+ @dataclass
17
+ class VisionResult:
18
+ """Structured output of a vision model describing a single image."""
19
+
20
+ caption: str = ""
21
+ ocr_text: str = ""
22
+ description: str = ""
23
+
24
+ def best_text(self) -> str:
25
+ """Text most suitable for embedding (description, else caption)."""
26
+ return self.description or self.caption or ""
27
+
28
+
29
+ @runtime_checkable
30
+ class VisionModel(Protocol):
31
+ """Turns image bytes into a caption + OCR text + description.
32
+
33
+ Implement this to plug in any vision-capable model (GPT-4o, Claude,
34
+ Gemini, a local Llama 3.2 Vision, etc.).
35
+ """
36
+
37
+ def analyze(
38
+ self,
39
+ image_bytes: bytes,
40
+ ext: str = "png",
41
+ width: int = 0,
42
+ height: int = 0,
43
+ ) -> VisionResult:
44
+ """Return a :class:`VisionResult` for one image. Must not raise on
45
+ model errors — return an empty ``VisionResult`` instead."""
46
+ ...
47
+
48
+
49
+ @runtime_checkable
50
+ class Embedder(Protocol):
51
+ """Turns text into fixed-length numeric vectors.
52
+
53
+ Implement this to plug in any embedding model (OpenAI, Cohere,
54
+ sentence-transformers, etc.).
55
+ """
56
+
57
+ #: Dimensionality of the vectors this embedder produces.
58
+ dim: int
59
+
60
+ def embed(self, texts: List[str]) -> List[Optional[List[float]]]:
61
+ """Embed a batch of texts. Returns one vector per input (same order);
62
+ ``None`` for empty or failed items. Implementations are encouraged to
63
+ batch internally to minimise API calls."""
64
+ ...
65
+
66
+
67
+ @runtime_checkable
68
+ class BlobStore(Protocol):
69
+ """Persists bytes and JSON to some backing store.
70
+
71
+ Implement this to plug in any storage target (local disk, Azure Blob,
72
+ S3, GCS, etc.). ``path`` is always a relative, forward-slash path.
73
+ """
74
+
75
+ def put_bytes(self, path: str, data: bytes, content_type: str = "") -> str:
76
+ """Store raw bytes at *path*. Returns a locator (URL or absolute path)."""
77
+ ...
78
+
79
+ def put_json(self, path: str, obj: object, compact: bool = False) -> str:
80
+ """Serialize *obj* to JSON and store at *path*. When *compact* is True,
81
+ use separator-only JSON (~30-40% smaller). Returns a locator."""
82
+ ...
83
+
84
+ def exists(self, path: str) -> bool:
85
+ """Return True if an object already exists at *path* (resume support)."""
86
+ ...
87
+
88
+
89
+ @runtime_checkable
90
+ class DocumentExtractor(Protocol):
91
+ """Reads a document file into the normalized document structure.
92
+
93
+ Implement this to support a new file format (PDF, Word, PowerPoint, Excel,
94
+ HTML, ...). The downstream pipeline (filtering, vision, chunking, embedding,
95
+ storage) is format-agnostic and consumes the structure returned here.
96
+
97
+ The returned document is a dict::
98
+
99
+ {
100
+ "metadata": {...},
101
+ "_base_name": "<file stem>",
102
+ "pgs": [ # a "page" = page / slide / sheet / section
103
+ {"pg_num": 1, "kind": "page", "txt": "...",
104
+ "tables": [[[...]]], "imgs": []},
105
+ ...
106
+ ],
107
+ }
108
+
109
+ and ``prepared_images`` is the list of images that passed filtering, each a
110
+ dict carrying ``image_bytes`` for downstream vision analysis.
111
+ """
112
+
113
+ #: Lower-case file extensions this extractor handles, e.g. ``(".pdf",)``.
114
+ extensions: Tuple[str, ...]
115
+
116
+ def extract(
117
+ self,
118
+ path: str,
119
+ image_filter: Optional[Any] = None,
120
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
121
+ """Return ``(document, prepared_images)`` for the file at *path*.
122
+
123
+ *image_filter* is an optional :class:`ImageFilterPipeline`; when omitted
124
+ the extractor should create a default one. Text-only extractors may
125
+ ignore it.
126
+ """
127
+ ...
128
+
129
+
130
+ @dataclass
131
+ class PipelineConfig:
132
+ """Tunable knobs shared across the pipeline."""
133
+
134
+ # Image filtering
135
+ min_image_size: int = 100
136
+ min_image_size_minor: int = 75
137
+ reference_img_dir: str = ""
138
+
139
+ # Vision / embedding
140
+ vision_workers: int = 6
141
+ embed_text_limit: int = 8000
142
+
143
+ # Chunking
144
+ chunk_target_tokens: int = 500
145
+ chunk_overlap_tokens: int = 50
146
+
147
+ # Storage layout (relative sub-folders under the store root)
148
+ images_subdir: str = "extracted_images"
149
+ doc_json_subdir: str = "jsons"
150
+ image_json_subdir: str = "image_jsons"
151
+ chunks_subdir: str = "chunks"