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,127 @@
1
+ """HTML extractor (.html / .htm).
2
+
3
+ Requires beautifulsoup4 (optional extra [html]).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ log = logging.getLogger("multixtract")
13
+
14
+ _REMOVE_TAGS = {"script", "style", "nav", "header", "footer"}
15
+ _CHARSET_RE = re.compile(rb'charset=["\']?([\w-]+)', re.IGNORECASE)
16
+
17
+
18
+ def _detect_encoding(raw: bytes) -> str:
19
+ m = _CHARSET_RE.search(raw[:4096])
20
+ if m:
21
+ return m.group(1).decode("ascii", errors="replace")
22
+ return "utf-8"
23
+
24
+
25
+ def _parse_table(table_tag) -> List[List[str]]:
26
+ rows = []
27
+ for tr in table_tag.find_all("tr"):
28
+ cells = [td.get_text(strip=True) for td in tr.find_all(["th", "td"])]
29
+ if cells:
30
+ rows.append(cells)
31
+ return rows
32
+
33
+
34
+ def _section_text_and_tables(tags, soup_cls):
35
+ """Given a list of tags forming a section, return (text, tables)."""
36
+ # Build a temporary wrapper so get_text works across the tag list.
37
+ wrapper = soup_cls.new_tag("div")
38
+ for tag in tags:
39
+ wrapper.append(tag.__copy__())
40
+ for bad in wrapper.find_all(_REMOVE_TAGS):
41
+ bad.decompose()
42
+ text = wrapper.get_text(separator="\n", strip=True)
43
+ tables = [_parse_table(tbl) for tbl in wrapper.find_all("table")]
44
+ tables = [t for t in tables if t]
45
+ return text, tables
46
+
47
+
48
+ class HtmlExtractor:
49
+ """DocumentExtractor for HTML files."""
50
+
51
+ extensions: Tuple[str, ...] = (".html", ".htm")
52
+
53
+ def extract(
54
+ self,
55
+ path: str,
56
+ image_filter: Optional[Any] = None,
57
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
58
+ base_name = os.path.splitext(os.path.basename(path))[0]
59
+ empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
60
+ try:
61
+ from bs4 import BeautifulSoup
62
+ except ImportError as exc:
63
+ raise ImportError(
64
+ "HTML support requires beautifulsoup4: pip install 'multixtract[html]'"
65
+ ) from exc
66
+ try:
67
+ with open(path, "rb") as fh:
68
+ raw = fh.read()
69
+
70
+ encoding = _detect_encoding(raw)
71
+ html = raw.decode(encoding, errors="replace")
72
+ soup = BeautifulSoup(html, "html.parser")
73
+
74
+ title = ""
75
+ title_tag = soup.find("title")
76
+ if title_tag:
77
+ title = title_tag.get_text(strip=True)
78
+
79
+ body = soup.body or soup
80
+
81
+ # Collect top-level nodes grouped by H1 boundaries.
82
+ # Each group: list of Tag objects that belong to one page.
83
+ groups: List[List[Any]] = []
84
+ current: List[Any] = []
85
+ for child in body.children:
86
+ if getattr(child, "name", None) == "h1":
87
+ if current:
88
+ groups.append(current)
89
+ current = [child]
90
+ else:
91
+ current.append(child)
92
+ if current:
93
+ groups.append(current)
94
+
95
+ # Drop groups that are pure whitespace (e.g. between tags)
96
+ groups = [g for g in groups if any(
97
+ getattr(t, "name", None) or str(t).strip() for t in g
98
+ )]
99
+
100
+ if not groups:
101
+ groups = [[body]]
102
+
103
+ pages = []
104
+ for pg_num, group in enumerate(groups, start=1):
105
+ text, tables = _section_text_and_tables(group, soup)
106
+ pages.append({
107
+ "pg_num": pg_num,
108
+ "txt": text,
109
+ "tables": tables,
110
+ "imgs": [],
111
+ })
112
+
113
+ document = {
114
+ "_base_name": base_name,
115
+ "metadata": {
116
+ "page_count": len(pages),
117
+ "format": "html",
118
+ "title": title,
119
+ },
120
+ "pgs": pages,
121
+ }
122
+ return document, []
123
+ except ImportError:
124
+ raise
125
+ except Exception:
126
+ log.warning("HtmlExtractor failed for %s", path, exc_info=True)
127
+ return empty, []
@@ -0,0 +1,149 @@
1
+ """Image file extractor (.png / .jpg / .jpeg / .tiff / .tif / .webp / .bmp).
2
+
3
+ Each image file becomes a one-page document whose single image is passed
4
+ directly to the vision pipeline. Multi-page TIFF files produce one page per
5
+ frame.
6
+
7
+ ``image_filter`` is accepted in the signature to satisfy the
8
+ ``DocumentExtractor`` protocol but is intentionally ignored — a standalone
9
+ image file is an explicit user choice, not embedded noise to filter.
10
+
11
+ Pillow is a mandatory core dependency so no optional extra is needed.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import logging
17
+ import os
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ log = logging.getLogger("multixtract")
21
+
22
+
23
+ class ImageExtractor:
24
+ """DocumentExtractor for standalone image files."""
25
+
26
+ extensions: Tuple[str, ...] = (
27
+ ".png", ".jpg", ".jpeg", ".tiff", ".tif", ".webp", ".bmp",
28
+ )
29
+
30
+ def extract(
31
+ self,
32
+ path: str,
33
+ image_filter: Optional[Any] = None,
34
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
35
+ """Return (document, prepared_images) for one image file.
36
+
37
+ ``image_filter`` is ignored — see module docstring.
38
+ Never raises; returns an empty document on failure.
39
+ """
40
+ from PIL import Image
41
+
42
+ base_name = os.path.splitext(os.path.basename(path))[0]
43
+ raw_ext = os.path.splitext(path)[1].lstrip(".").lower()
44
+ # Normalise .tif -> tiff, .jpg -> jpeg for consistency.
45
+ ext_map = {"tif": "tiff", "jpg": "jpeg"}
46
+ ext = ext_map.get(raw_ext, raw_ext)
47
+
48
+ empty_doc = {"_base_name": base_name, "metadata": {}, "pgs": []}
49
+
50
+ try:
51
+ with open(path, "rb") as fh:
52
+ file_bytes = fh.read()
53
+
54
+ with Image.open(io.BytesIO(file_bytes)) as img:
55
+ is_multipage = hasattr(img, "n_frames") and img.n_frames > 1
56
+
57
+ if is_multipage:
58
+ return self._extract_multipage_tiff(
59
+ img, file_bytes, base_name, ext
60
+ )
61
+
62
+ width, height = img.size
63
+
64
+ document = {
65
+ "_base_name": base_name,
66
+ "metadata": {"page_count": 1, "format": ext},
67
+ "pgs": [
68
+ {
69
+ "pg_num": 1,
70
+ "txt": "",
71
+ "tables": [],
72
+ "imgs": [],
73
+ }
74
+ ],
75
+ }
76
+ prepared = [
77
+ {
78
+ "image_id": f"{base_name}__p1_img0",
79
+ "page_number": 1,
80
+ "img_idx": 0,
81
+ "image_bytes": file_bytes,
82
+ "ext": ext,
83
+ "width": width,
84
+ "height": height,
85
+ "img_path": f"pg1_img0.{ext}",
86
+ }
87
+ ]
88
+ return document, prepared
89
+
90
+ except Exception:
91
+ log.warning("ImageExtractor failed for %s", path, exc_info=True)
92
+ return empty_doc, []
93
+
94
+ def _extract_multipage_tiff(
95
+ self,
96
+ img,
97
+ file_bytes: bytes,
98
+ base_name: str,
99
+ ext: str,
100
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
101
+ pages = []
102
+ prepared = []
103
+
104
+ for frame_idx in range(img.n_frames):
105
+ pg_num = frame_idx + 1
106
+ try:
107
+ img.seek(frame_idx)
108
+ width, height = img.size
109
+
110
+ buf = io.BytesIO()
111
+ frame = img.convert("RGB")
112
+ try:
113
+ frame.save(buf, format="PNG")
114
+ finally:
115
+ frame.close()
116
+ frame_bytes = buf.getvalue()
117
+
118
+ pages.append(
119
+ {
120
+ "pg_num": pg_num,
121
+ "txt": "",
122
+ "tables": [],
123
+ "imgs": [],
124
+ }
125
+ )
126
+ prepared.append(
127
+ {
128
+ "image_id": f"{base_name}__p{pg_num}_img0",
129
+ "page_number": pg_num,
130
+ "img_idx": 0,
131
+ "image_bytes": frame_bytes,
132
+ "ext": "png",
133
+ "width": width,
134
+ "height": height,
135
+ "img_path": f"pg{pg_num}_img0.png",
136
+ }
137
+ )
138
+ except Exception:
139
+ log.warning(
140
+ "ImageExtractor: failed to read frame %d of %s",
141
+ frame_idx, base_name, exc_info=True,
142
+ )
143
+
144
+ document = {
145
+ "_base_name": base_name,
146
+ "metadata": {"page_count": len(pages), "format": ext},
147
+ "pgs": pages,
148
+ }
149
+ return document, prepared
@@ -0,0 +1,148 @@
1
+ """Legacy Office format extractors (.doc, .ppt, .xls) via LibreOffice conversion.
2
+
3
+ The old binary formats (.doc, .ppt) have no reliable native Python parser, so
4
+ this module converts them to a modern format with a headless LibreOffice call,
5
+ then delegates extraction to the registered extractor for the target format.
6
+
7
+ Strategy (per the project's design decision):
8
+ * Prefer converting to the modern *native* equivalent (.doc -> .docx,
9
+ .ppt -> .pptx) for the best fidelity and a consistent output schema.
10
+ * Fall back to PDF when no native extractor is registered (the bundled
11
+ PdfExtractor always works), so legacy files are handled out of the box.
12
+ * To force PDF, construct ``ConvertingExtractor(..., targets=(".pdf",))``.
13
+
14
+ LibreOffice is a SYSTEM dependency (NOT pip-installable). If it is missing,
15
+ :meth:`ConvertingExtractor.extract` raises a clear, actionable error telling the
16
+ user to install LibreOffice or pre-convert the file to .docx/.pptx themselves.
17
+
18
+ apt-get install libreoffice
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import tempfile
26
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
27
+
28
+ from .registry import ExtractorRegistry, default_registry
29
+
30
+
31
+ def find_libreoffice() -> Optional[str]:
32
+ """Return the path to the LibreOffice/soffice binary, or None if absent."""
33
+ for name in ("libreoffice", "soffice"):
34
+ path = shutil.which(name)
35
+ if path:
36
+ return path
37
+ return None
38
+
39
+
40
+ def convert_with_libreoffice(
41
+ src_path: str,
42
+ target_ext: str,
43
+ out_dir: str,
44
+ timeout: int = 120,
45
+ ) -> str:
46
+ """Convert *src_path* to *target_ext* (e.g. ``".docx"`` / ``".pdf"``) inside
47
+ *out_dir* using headless LibreOffice. Returns the converted file path.
48
+ """
49
+ soffice = find_libreoffice()
50
+ if soffice is None:
51
+ raise RuntimeError(
52
+ "LibreOffice is required to process legacy .doc/.ppt files but was "
53
+ "not found. Install it (apt-get install libreoffice) or convert the "
54
+ "file to a modern format (.docx/.pptx) yourself first."
55
+ )
56
+ fmt = target_ext.lstrip(".")
57
+ proc = subprocess.run(
58
+ [soffice, "--headless", "--convert-to", fmt, "--outdir", out_dir, src_path],
59
+ capture_output=True, text=True, timeout=timeout,
60
+ )
61
+ if proc.returncode != 0:
62
+ raise RuntimeError(
63
+ f"LibreOffice failed to convert {os.path.basename(src_path)} -> {fmt} "
64
+ f"(exit {proc.returncode}): {(proc.stderr or '').strip()[:300]}"
65
+ )
66
+ # LibreOffice normalizes the output filename (spaces → underscores, special
67
+ # chars dropped/replaced), so we can't predict the exact stem. Scan out_dir
68
+ # for any file with the right extension instead of checking a fixed path.
69
+ matches = [
70
+ f for f in os.listdir(out_dir)
71
+ if f.lower().endswith(f".{fmt}")
72
+ ]
73
+ if not matches:
74
+ raise RuntimeError(
75
+ f"LibreOffice exited 0 but produced no .{fmt} file in {out_dir!r} "
76
+ f"(converting {os.path.basename(src_path)})"
77
+ )
78
+ return os.path.join(out_dir, matches[0])
79
+
80
+
81
+ class ConvertingExtractor:
82
+ """DocumentExtractor that converts a legacy file, then delegates.
83
+
84
+ It tries each extension in *targets* in order, using the first one that has
85
+ a registered extractor, converts the input to that format via LibreOffice,
86
+ and delegates extraction to that target extractor. This means it transparently
87
+ upgrades to native extraction (.docx/.pptx) once those extractors exist,
88
+ while working today via the PDF fallback.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ source_exts: Iterable[str],
94
+ targets: Iterable[str] = (".pdf",),
95
+ registry: Optional[ExtractorRegistry] = None,
96
+ timeout: int = 120,
97
+ ) -> None:
98
+ self.extensions: Tuple[str, ...] = tuple(
99
+ e if e.startswith(".") else f".{e}" for e in source_exts
100
+ )
101
+ self.targets: Tuple[str, ...] = tuple(
102
+ t if t.startswith(".") else f".{t}" for t in targets
103
+ )
104
+ self._registry = registry
105
+ self.timeout = timeout
106
+
107
+ def _resolve_target(self, reg: ExtractorRegistry):
108
+ """Return ``(target_ext, target_extractor)`` for the first target that
109
+ has a registered extractor."""
110
+ for ext in self.targets:
111
+ try:
112
+ return ext, reg.get(f"_probe{ext}")
113
+ except ValueError:
114
+ continue
115
+ raise RuntimeError(
116
+ f"No extractor registered for any conversion target {self.targets} "
117
+ f"of {self.extensions}. Register a target extractor (e.g. PdfExtractor) first."
118
+ )
119
+
120
+ def extract(
121
+ self,
122
+ path: str,
123
+ image_filter: Optional[Any] = None,
124
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
125
+ reg = self._registry or default_registry
126
+ target_ext, delegate = self._resolve_target(reg) # fail fast before converting
127
+ with tempfile.TemporaryDirectory(prefix="docextract_legacy_") as tmp:
128
+ converted = convert_with_libreoffice(path, target_ext, tmp, timeout=self.timeout)
129
+ document, prepared = delegate.extract(converted, image_filter=image_filter)
130
+ # Preserve the ORIGINAL file's base name, not the temp converted name.
131
+ document["_base_name"] = os.path.splitext(os.path.basename(path))[0]
132
+ document.setdefault("metadata", {})["converted_from"] = os.path.splitext(path)[1].lower()
133
+ return document, prepared
134
+
135
+
136
+ # Built-in legacy extractors.
137
+ # .doc -> prefer .docx (native), fall back to .pdf
138
+ # .ppt -> prefer .pptx (native), fall back to .pdf
139
+ # .xls -> prefer .xlsx (native), fall back to .pdf
140
+ # DocxExtractor, PptxExtractor, and ExcelExtractor are registered in
141
+ # extractors/__init__.py, so legacy formats automatically get native high-fidelity
142
+ # extraction via LibreOffice conversion. PDF remains the fallback.
143
+ legacy_doc_extractor = ConvertingExtractor((".doc",), targets=(".docx", ".pdf"))
144
+ legacy_ppt_extractor = ConvertingExtractor((".ppt",), targets=(".pptx", ".pdf"))
145
+ legacy_odt_extractor = ConvertingExtractor((".odt",), targets=(".docx", ".pdf"))
146
+ legacy_odp_extractor = ConvertingExtractor((".odp",), targets=(".pptx", ".pdf"))
147
+ legacy_ods_extractor = ConvertingExtractor((".ods",), targets=(".xlsx", ".pdf"))
148
+ legacy_xls_extractor = ConvertingExtractor((".xls",), targets=(".xlsx", ".pdf"))
@@ -0,0 +1,104 @@
1
+ """Markdown extractor (.md).
2
+
3
+ Splits on H1 headings into logical pages. Registered after TextExtractor so
4
+ it wins for .md — later registration overwrites earlier in the registry.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ log = logging.getLogger("multixtract")
14
+
15
+ _H1_RE = re.compile(r"^# .+", re.MULTILINE)
16
+ _H2_RE = re.compile(r"^## .+", re.MULTILINE)
17
+ # A pipe-table row: starts and ends with |, contains at least one |
18
+ _TABLE_ROW_RE = re.compile(r"^\|.+\|$")
19
+ # Separator row: only |, -, :, and spaces
20
+ _TABLE_SEP_RE = re.compile(r"^\|[\s\-:|]+\|$")
21
+
22
+
23
+ def _parse_tables(text: str) -> List[List[List[str]]]:
24
+ """Return all GFM pipe tables found in text as list-of-list-of-strings."""
25
+ tables = []
26
+ lines = text.splitlines()
27
+ i = 0
28
+ while i < len(lines):
29
+ if _TABLE_ROW_RE.match(lines[i].strip()):
30
+ block = []
31
+ while i < len(lines) and _TABLE_ROW_RE.match(lines[i].strip()):
32
+ row = lines[i].strip()
33
+ if _TABLE_SEP_RE.match(row):
34
+ i += 1
35
+ continue
36
+ cells = [c.strip() for c in row.strip("|").split("|")]
37
+ block.append(cells)
38
+ i += 1
39
+ if block:
40
+ tables.append(block)
41
+ else:
42
+ i += 1
43
+ return tables
44
+
45
+
46
+ def _split_on_h1(text: str) -> List[str]:
47
+ """Split text into sections on H1 boundaries, preserving the heading."""
48
+ positions = [m.start() for m in _H1_RE.finditer(text)]
49
+ if not positions:
50
+ return [text]
51
+ sections = []
52
+ for idx, start in enumerate(positions):
53
+ end = positions[idx + 1] if idx + 1 < len(positions) else len(text)
54
+ sections.append(text[start:end])
55
+ # Content before the first H1 (preamble), if any
56
+ if positions[0] > 0:
57
+ sections.insert(0, text[: positions[0]])
58
+ return [s for s in sections if s.strip()]
59
+
60
+
61
+ class MarkdownExtractor:
62
+ """DocumentExtractor for Markdown files."""
63
+
64
+ extensions: Tuple[str, ...] = (".md",)
65
+
66
+ def extract(
67
+ self,
68
+ path: str,
69
+ image_filter: Optional[Any] = None,
70
+ ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
71
+ base_name = os.path.splitext(os.path.basename(path))[0]
72
+ empty = {"_base_name": base_name, "metadata": {}, "pgs": []}
73
+ try:
74
+ try:
75
+ with open(path, encoding="utf-8") as fh:
76
+ text = fh.read()
77
+ except UnicodeDecodeError:
78
+ with open(path, encoding="latin-1", errors="replace") as fh:
79
+ text = fh.read()
80
+
81
+ sections = _split_on_h1(text)
82
+ pages = []
83
+ for pg_num, section in enumerate(sections, start=1):
84
+ pages.append({
85
+ "pg_num": pg_num,
86
+ "txt": section,
87
+ "tables": _parse_tables(section),
88
+ "imgs": [],
89
+ })
90
+
91
+ document = {
92
+ "_base_name": base_name,
93
+ "metadata": {
94
+ "page_count": len(pages),
95
+ "format": "md",
96
+ "h1_count": len(_H1_RE.findall(text)),
97
+ "h2_count": len(_H2_RE.findall(text)),
98
+ },
99
+ "pgs": pages,
100
+ }
101
+ return document, []
102
+ except Exception:
103
+ log.warning("MarkdownExtractor failed for %s", path, exc_info=True)
104
+ return empty, []