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