fylepy 0.1.0__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.
- fyle/__init__.py +46 -0
- fyle/_core/__init__.py +5 -0
- fyle/_core/api.py +164 -0
- fyle/_core/chunking.py +107 -0
- fyle/_core/document.py +345 -0
- fyle/_core/fetcher.py +68 -0
- fyle/_core/registry.py +107 -0
- fyle/_core/sniffer.py +251 -0
- fyle/_readers/__init__.py +32 -0
- fyle/_readers/_md_structure.py +208 -0
- fyle/_readers/_whisper.py +126 -0
- fyle/_readers/archive/__init__.py +8 -0
- fyle/_readers/archive/stdlib.py +513 -0
- fyle/_readers/audio/__init__.py +9 -0
- fyle/_readers/audio/faster_whisper.py +162 -0
- fyle/_readers/base.py +70 -0
- fyle/_readers/csv/__init__.py +6 -0
- fyle/_readers/csv/stdlib.py +119 -0
- fyle/_readers/docx/__init__.py +6 -0
- fyle/_readers/docx/mammoth.py +130 -0
- fyle/_readers/html/__init__.py +6 -0
- fyle/_readers/html/markdownify.py +113 -0
- fyle/_readers/image/__init__.py +18 -0
- fyle/_readers/image/stdlib.py +136 -0
- fyle/_readers/markdown/__init__.py +6 -0
- fyle/_readers/markdown/stdlib.py +61 -0
- fyle/_readers/pdf/__init__.py +2 -0
- fyle/_readers/pdf/pymupdf4llm.py +202 -0
- fyle/_readers/pptx/__init__.py +7 -0
- fyle/_readers/pptx/python_pptx.py +306 -0
- fyle/_readers/sqlite/__init__.py +8 -0
- fyle/_readers/sqlite/stdlib.py +366 -0
- fyle/_readers/text/__init__.py +7 -0
- fyle/_readers/text/stdlib.py +76 -0
- fyle/_readers/video/__init__.py +10 -0
- fyle/_readers/video/scenedetect.py +330 -0
- fyle/_readers/xlsx/__init__.py +6 -0
- fyle/_readers/xlsx/openpyxl.py +158 -0
- fyle/errors.py +42 -0
- fyle/sqlite.py +175 -0
- fylepy-0.1.0.dist-info/METADATA +272 -0
- fylepy-0.1.0.dist-info/RECORD +44 -0
- fylepy-0.1.0.dist-info/WHEEL +4 -0
- fylepy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Audio reader backed by ``faster-whisper``.
|
|
2
|
+
|
|
3
|
+
Produces a ``Document`` whose ``doc.text`` is the time-stamped Markdown
|
|
4
|
+
transcript of the input audio. One ``Page`` per file; per-segment
|
|
5
|
+
timestamps (``[MM:SS]``) are inlined so the LLM can cite moments.
|
|
6
|
+
|
|
7
|
+
Why this file exists
|
|
8
|
+
--------------------
|
|
9
|
+
Transcription is the only credible way to turn audio into something an
|
|
10
|
+
LLM "sees". Whisper is the only open-source ASR model with broad
|
|
11
|
+
multilingual coverage that is practical on a CPU; ``faster-whisper``
|
|
12
|
+
(CTranslate2 int8) is the fastest Python binding and avoids the
|
|
13
|
+
PyTorch dependency chain. See ``.._whisper`` for the shared model cache
|
|
14
|
+
and transcript formatter.
|
|
15
|
+
|
|
16
|
+
Source-path vs bytes
|
|
17
|
+
--------------------
|
|
18
|
+
faster-whisper decodes audio via PyAV, which is happiest with a real
|
|
19
|
+
filesystem path. We write the bytes to a NamedTemporaryFile with the
|
|
20
|
+
right suffix (important: PyAV sometimes sniffs the container from the
|
|
21
|
+
extension), transcribe, then delete. The temp file is **not** the
|
|
22
|
+
input file itself even when a local ``source_path`` is available —
|
|
23
|
+
keeping a single code path simplifies URL / bytes / file-like inputs.
|
|
24
|
+
|
|
25
|
+
File naming rule: ``faster_whisper.py`` — the core driver library.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import tempfile
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
from .. import _whisper
|
|
36
|
+
from ..base import Reader
|
|
37
|
+
from ..._core.document import Document, Meta, Page
|
|
38
|
+
from ...errors import ParseError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FasterWhisperAudioReader(Reader):
|
|
42
|
+
name = "faster-whisper"
|
|
43
|
+
formats = ("audio",)
|
|
44
|
+
is_default = True
|
|
45
|
+
|
|
46
|
+
def read(
|
|
47
|
+
self,
|
|
48
|
+
data: bytes,
|
|
49
|
+
*,
|
|
50
|
+
source_name: Optional[str] = None,
|
|
51
|
+
source_path: Optional[str] = None,
|
|
52
|
+
**_,
|
|
53
|
+
) -> Document:
|
|
54
|
+
if not data:
|
|
55
|
+
raise ParseError("faster-whisper reader: input is empty")
|
|
56
|
+
|
|
57
|
+
# Preserve the suffix so PyAV can sniff the container correctly.
|
|
58
|
+
# ``.mp3`` vs ``.m4a`` share different demuxers; a mislabelled
|
|
59
|
+
# suffix occasionally trips PyAV's probing.
|
|
60
|
+
suffix = _pick_suffix(source_name)
|
|
61
|
+
warnings: list[str] = []
|
|
62
|
+
|
|
63
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
64
|
+
prefix="fyle-audio-",
|
|
65
|
+
suffix=suffix,
|
|
66
|
+
delete=False,
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
tmp.write(data)
|
|
70
|
+
tmp.close()
|
|
71
|
+
try:
|
|
72
|
+
segments, info = _whisper.transcribe(tmp.name)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
raise ParseError(
|
|
75
|
+
f"faster-whisper reader: transcription failed: {e}"
|
|
76
|
+
) from e
|
|
77
|
+
finally:
|
|
78
|
+
try:
|
|
79
|
+
os.unlink(tmp.name)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
text = _render_page_text(
|
|
84
|
+
source_name=source_name,
|
|
85
|
+
duration_sec=float(info.get("duration", 0.0) or 0.0),
|
|
86
|
+
language=info.get("language"),
|
|
87
|
+
segments=segments,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Record what the model heard: language + duration + model size.
|
|
91
|
+
# These go into meta.warnings so they surface in the example's
|
|
92
|
+
# summary without polluting the content surface.
|
|
93
|
+
if info.get("language"):
|
|
94
|
+
warnings.append(
|
|
95
|
+
f"detected language: {info['language']} "
|
|
96
|
+
f"(p={info.get('language_probability', 0):.2f})"
|
|
97
|
+
)
|
|
98
|
+
if info.get("duration"):
|
|
99
|
+
warnings.append(
|
|
100
|
+
f"audio duration: {_whisper.format_timestamp(info['duration'])}"
|
|
101
|
+
)
|
|
102
|
+
warnings.append(f"whisper model: {_whisper.model_size()}")
|
|
103
|
+
|
|
104
|
+
title = Path(source_name).stem if source_name else None
|
|
105
|
+
page = Page(text=text, number=1)
|
|
106
|
+
meta = Meta(
|
|
107
|
+
format="audio",
|
|
108
|
+
pages=1,
|
|
109
|
+
size=len(data),
|
|
110
|
+
title=title,
|
|
111
|
+
reader=self.name,
|
|
112
|
+
created_at=datetime.now(timezone.utc),
|
|
113
|
+
warnings=warnings,
|
|
114
|
+
)
|
|
115
|
+
return Document(pages=[page], meta=meta)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_KNOWN_AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".flac", ".ogg", ".opus", ".aac"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_page_text(
|
|
122
|
+
*,
|
|
123
|
+
source_name: Optional[str],
|
|
124
|
+
duration_sec: float,
|
|
125
|
+
language: Optional[str],
|
|
126
|
+
segments: list[dict],
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Render the audio page as Markdown with a lightweight header.
|
|
129
|
+
|
|
130
|
+
Header mirrors the video reader (Duration + Language) so callers that
|
|
131
|
+
feed ``doc.text`` into an LLM without ``doc.meta`` still see the core
|
|
132
|
+
context up front. Kept minimal on purpose: model size / confidence go
|
|
133
|
+
into ``meta.warnings`` rather than the content surface.
|
|
134
|
+
"""
|
|
135
|
+
title = source_name or "(unnamed audio)"
|
|
136
|
+
dur = _whisper.format_timestamp(duration_sec) if duration_sec else "-"
|
|
137
|
+
lang = language or "-"
|
|
138
|
+
transcript = _whisper.format_transcript(segments)
|
|
139
|
+
return (
|
|
140
|
+
f"# Audio: {title}\n"
|
|
141
|
+
"\n"
|
|
142
|
+
f"- Duration: `{dur}`\n"
|
|
143
|
+
f"- Language: `{lang}`\n"
|
|
144
|
+
"\n"
|
|
145
|
+
"## Transcript\n"
|
|
146
|
+
"\n"
|
|
147
|
+
f"{transcript}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _pick_suffix(source_name: Optional[str]) -> str:
|
|
152
|
+
"""Return a file suffix that helps PyAV sniff the container.
|
|
153
|
+
|
|
154
|
+
Falls back to ``.wav`` (safest generic guess) when the caller cannot
|
|
155
|
+
supply a name; PyAV will still probe magic bytes but a plausible
|
|
156
|
+
extension keeps some demuxers honest.
|
|
157
|
+
"""
|
|
158
|
+
if source_name:
|
|
159
|
+
ext = Path(source_name).suffix.lower()
|
|
160
|
+
if ext in _KNOWN_AUDIO_SUFFIXES:
|
|
161
|
+
return ext
|
|
162
|
+
return ".wav"
|
fyle/_readers/base.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Reader abstract base class — every reader subclasses this.
|
|
2
|
+
|
|
3
|
+
Subclasses are auto-registered via ``__init_subclass__`` at definition time;
|
|
4
|
+
no manual registration call is needed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .._core.document import Document
|
|
12
|
+
from .._core.registry import _register
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Reader(ABC):
|
|
16
|
+
"""Abstract base class for all readers.
|
|
17
|
+
|
|
18
|
+
Subclasses must define the following class attributes:
|
|
19
|
+
name: str — globally unique reader name (e.g. ``"pymupdf4llm"``).
|
|
20
|
+
formats: tuple[str, ...] — supported format names (e.g. ``("pdf",)``).
|
|
21
|
+
is_default: bool = False — whether this is the default reader for each
|
|
22
|
+
format listed in ``formats``.
|
|
23
|
+
|
|
24
|
+
Subclasses must implement ``read(self, data, *, source_name=None, source_path=None) -> Document``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str = ""
|
|
28
|
+
formats: tuple[str, ...] = ()
|
|
29
|
+
is_default: bool = False
|
|
30
|
+
|
|
31
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
32
|
+
super().__init_subclass__(**kwargs)
|
|
33
|
+
# Only auto-register concrete subclasses; skip intermediate abstract bases
|
|
34
|
+
# that still carry @abstractmethod definitions.
|
|
35
|
+
if getattr(cls, "__abstractmethods__", None):
|
|
36
|
+
return
|
|
37
|
+
if not cls.name or not cls.formats:
|
|
38
|
+
# Subclasses used purely as organisational layers (no name/formats)
|
|
39
|
+
# are allowed and are skipped by the registry.
|
|
40
|
+
return
|
|
41
|
+
_register(cls)
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def read(
|
|
45
|
+
self,
|
|
46
|
+
data: bytes,
|
|
47
|
+
*,
|
|
48
|
+
source_name: Optional[str] = None,
|
|
49
|
+
source_path: Optional[str] = None,
|
|
50
|
+
) -> Document:
|
|
51
|
+
"""Parse raw bytes and return a ``Document``.
|
|
52
|
+
|
|
53
|
+
The dispatcher (``_core.api._normalize``) is responsible for unifying
|
|
54
|
+
path / bytes / file-like / URL inputs into bytes before the reader
|
|
55
|
+
runs; readers never handle polymorphic inputs themselves.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
data: In-memory file contents as ``bytes``.
|
|
59
|
+
source_name: Original file name (from a local path or URL path),
|
|
60
|
+
used as a fallback for ``meta.title``.
|
|
61
|
+
source_path: Absolute filesystem path of a local file source, or
|
|
62
|
+
``None`` for URL / bytes / file-like inputs. Most readers
|
|
63
|
+
ignore this; the archive reader uses it to extract into the
|
|
64
|
+
same directory as the source file.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A ``Document``. Fatal failures raise ``fyle.ParseError``; partial
|
|
68
|
+
failures are recorded in ``doc.meta.warnings``.
|
|
69
|
+
"""
|
|
70
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""CSV reader backed by the Python standard library (``csv`` + ``tabulate``).
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Decode bytes (UTF-8 with BOM; latin-1 fallback).
|
|
5
|
+
- Use the stdlib ``csv`` module with dialect sniffing to tolerate common
|
|
6
|
+
separators (``,`` / ``;`` / tab / ``|``).
|
|
7
|
+
- Emit a single ``Table`` and a single ``Page`` whose ``.text`` is a
|
|
8
|
+
Markdown table produced by ``tabulate`` — never hand-assembled.
|
|
9
|
+
|
|
10
|
+
Assumption: first row is the header. For header-less CSV a warning is
|
|
11
|
+
recorded and the first row is still used as header (downstream can inspect
|
|
12
|
+
``table.rows`` for the raw cells if needed).
|
|
13
|
+
|
|
14
|
+
File naming rule: ``stdlib.py`` — the driver is the stdlib ``csv`` module;
|
|
15
|
+
``tabulate`` is a post-processor and does not determine the file name.
|
|
16
|
+
``Reader.name`` is ``csv-stdlib`` to keep the registry key unique.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv as _csv
|
|
21
|
+
import io
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from tabulate import tabulate
|
|
27
|
+
|
|
28
|
+
from ..base import Reader
|
|
29
|
+
from ..._core.document import Document, Meta, Page, Table
|
|
30
|
+
from ...errors import ParseError
|
|
31
|
+
from ..text.stdlib import _decode_text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Dialect sniffer only looks at this many bytes; enough for a realistic CSV header.
|
|
35
|
+
_SNIFF_SAMPLE = 4096
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _escape_md_cell(cell: str) -> str:
|
|
39
|
+
"""Escape a single cell so it is safe inside a Markdown pipe table.
|
|
40
|
+
|
|
41
|
+
Markdown tables cannot carry literal ``|`` or newlines inside a cell.
|
|
42
|
+
We replace ``|`` with ``\\|`` and convert line breaks to ``<br>`` (widely
|
|
43
|
+
supported by Markdown renderers, including GitHub).
|
|
44
|
+
"""
|
|
45
|
+
if not isinstance(cell, str):
|
|
46
|
+
cell = "" if cell is None else str(cell)
|
|
47
|
+
return cell.replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CsvReader(Reader):
|
|
51
|
+
name = "csv-stdlib"
|
|
52
|
+
formats = ("csv",)
|
|
53
|
+
is_default = True
|
|
54
|
+
|
|
55
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
56
|
+
warnings: list[str] = []
|
|
57
|
+
text, decode_warning = _decode_text(data)
|
|
58
|
+
if decode_warning:
|
|
59
|
+
warnings.append(decode_warning)
|
|
60
|
+
|
|
61
|
+
# Sniff the dialect; fall back to the default ``excel`` (comma) dialect
|
|
62
|
+
# if the sample is too ambiguous.
|
|
63
|
+
sample = text[:_SNIFF_SAMPLE]
|
|
64
|
+
try:
|
|
65
|
+
dialect = _csv.Sniffer().sniff(sample, delimiters=",;\t|")
|
|
66
|
+
except _csv.Error:
|
|
67
|
+
dialect = _csv.excel
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
rows = list(_csv.reader(io.StringIO(text), dialect=dialect))
|
|
71
|
+
except _csv.Error as e:
|
|
72
|
+
raise ParseError(f"csv parse failed: {e}") from e
|
|
73
|
+
|
|
74
|
+
if not rows:
|
|
75
|
+
headers: list[str] = []
|
|
76
|
+
body: list[list[str]] = []
|
|
77
|
+
md_table = ""
|
|
78
|
+
else:
|
|
79
|
+
headers = rows[0]
|
|
80
|
+
body = rows[1:]
|
|
81
|
+
# Pre-escape cells for Markdown-table safety before handing off to
|
|
82
|
+
# ``tabulate``. Three hazards that ``tabulate`` itself does *not*
|
|
83
|
+
# handle:
|
|
84
|
+
# 1. a literal ``|`` inside a cell breaks the column structure.
|
|
85
|
+
# 2. a newline inside a cell is not legal in a Markdown table.
|
|
86
|
+
# 3. numeric-looking strings get right-aligned and re-formatted,
|
|
87
|
+
# which silently drops leading zeros (zip codes, phone
|
|
88
|
+
# numbers). ``disable_numparse=True`` fixes that.
|
|
89
|
+
safe_headers = [_escape_md_cell(h) for h in headers]
|
|
90
|
+
safe_body = [[_escape_md_cell(c) for c in row] for row in body]
|
|
91
|
+
md_table = tabulate(
|
|
92
|
+
safe_body,
|
|
93
|
+
headers=safe_headers,
|
|
94
|
+
tablefmt="pipe",
|
|
95
|
+
disable_numparse=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
title: Optional[str] = None
|
|
99
|
+
if source_name:
|
|
100
|
+
try:
|
|
101
|
+
title = Path(source_name).stem or None
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
title = None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
table = Table(text=md_table, rows=body, headers=headers, page=1)
|
|
107
|
+
page = Page(text=md_table, number=1, tables=[table])
|
|
108
|
+
meta = Meta(
|
|
109
|
+
format="csv",
|
|
110
|
+
pages=1,
|
|
111
|
+
size=len(data),
|
|
112
|
+
title=title,
|
|
113
|
+
reader=self.name,
|
|
114
|
+
created_at=datetime.now(timezone.utc),
|
|
115
|
+
warnings=warnings,
|
|
116
|
+
)
|
|
117
|
+
return Document(pages=[page], meta=meta)
|
|
118
|
+
except Exception as e: # pragma: no cover - defensive
|
|
119
|
+
raise ParseError(f"csv-stdlib reader failed to build Document: {e}") from e
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""DOCX reader — uses ``mammoth`` for Word → HTML → Markdown conversion.
|
|
2
|
+
|
|
3
|
+
File name inside the subpackage is the *core driver library* (``mammoth``);
|
|
4
|
+
``markdownify`` is a post-processor in the DOCX → HTML → Markdown chain.
|
|
5
|
+
"""
|
|
6
|
+
from . import mammoth # noqa: F401
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""DOCX reader backed by ``mammoth``.
|
|
2
|
+
|
|
3
|
+
Pipeline: DOCX → HTML (via ``mammoth.convert_to_html``) → Markdown (via
|
|
4
|
+
``markdownify``).
|
|
5
|
+
|
|
6
|
+
Why the two-stage pipeline rather than ``mammoth.convert_to_markdown``?
|
|
7
|
+
``mammoth`` 's Markdown target is known to be lossy on tables: it emits
|
|
8
|
+
each cell as a separate paragraph instead of a Markdown pipe table. Its
|
|
9
|
+
HTML target, by contrast, is faithful — tables render as real ``<table>``
|
|
10
|
+
elements. ``markdownify`` then converts HTML tables into Markdown pipe
|
|
11
|
+
tables cleanly. Each library does what it is good at.
|
|
12
|
+
|
|
13
|
+
Inline images survive the pipeline: ``mammoth`` embeds them as
|
|
14
|
+
``data:image/...;base64,...`` URLs in the HTML, and ``markdownify``
|
|
15
|
+
preserves those in the Markdown output. Structural extraction (tables,
|
|
16
|
+
images) is delegated to the shared ``_md_structure`` module so DOCX,
|
|
17
|
+
HTML and Markdown readers all present a consistent ``doc.tables`` /
|
|
18
|
+
``doc.images`` surface.
|
|
19
|
+
|
|
20
|
+
All content lives in a single ``Page``. DOCX has no native pagination
|
|
21
|
+
(page breaks in Word are rendering artifacts, not data), so v0.2 returns
|
|
22
|
+
``pages=[one_page]`` rather than forging a page number. This is noted in
|
|
23
|
+
``meta.warnings`` for transparency.
|
|
24
|
+
|
|
25
|
+
``python-docx`` is used only for core properties (title / author /
|
|
26
|
+
created_at) — ``mammoth`` does not expose those.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
from datetime import timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
from ..base import Reader
|
|
36
|
+
from ..._core.document import Document, Meta, Page
|
|
37
|
+
from ...errors import ParseError
|
|
38
|
+
from .._md_structure import extract_images, extract_tables
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DocxReader(Reader):
|
|
42
|
+
name = "mammoth"
|
|
43
|
+
formats = ("docx",)
|
|
44
|
+
is_default = True
|
|
45
|
+
|
|
46
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
47
|
+
try:
|
|
48
|
+
import mammoth
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
raise ParseError(
|
|
51
|
+
"mammoth is required for DOCX parsing: pip install mammoth"
|
|
52
|
+
) from e
|
|
53
|
+
try:
|
|
54
|
+
from markdownify import markdownify as _to_md
|
|
55
|
+
except ImportError as e:
|
|
56
|
+
raise ParseError(
|
|
57
|
+
"markdownify is required for DOCX parsing: pip install markdownify"
|
|
58
|
+
) from e
|
|
59
|
+
|
|
60
|
+
warnings: list[str] = []
|
|
61
|
+
|
|
62
|
+
# Stage 1: DOCX -> HTML via mammoth (tables, images, headings intact).
|
|
63
|
+
try:
|
|
64
|
+
result = mammoth.convert_to_html(io.BytesIO(data))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise ParseError(f"mammoth.convert_to_html failed: {e}") from e
|
|
67
|
+
html = result.value or ""
|
|
68
|
+
for msg in getattr(result, "messages", []) or []:
|
|
69
|
+
m_type = getattr(msg, "type", "warning")
|
|
70
|
+
m_text = getattr(msg, "message", str(msg))
|
|
71
|
+
if m_type in ("warning", "error"):
|
|
72
|
+
warnings.append(f"mammoth: {m_text}")
|
|
73
|
+
|
|
74
|
+
# Stage 2: HTML -> Markdown via markdownify. ``heading_style="ATX"``
|
|
75
|
+
# selects ``# Heading`` (the dialect we normalise on everywhere).
|
|
76
|
+
try:
|
|
77
|
+
md = _to_md(html, heading_style="ATX")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise ParseError(f"markdownify failed on DOCX HTML: {e}") from e
|
|
80
|
+
|
|
81
|
+
# 2b. Core metadata via python-docx (best-effort).
|
|
82
|
+
title, author, created_at = _read_core_props(data, warnings)
|
|
83
|
+
|
|
84
|
+
if not title and source_name:
|
|
85
|
+
title = Path(source_name).stem or None
|
|
86
|
+
|
|
87
|
+
# 3. Structural extraction from the produced Markdown. The DOCX
|
|
88
|
+
# pipeline yields standard GFM pipe tables (from markdownify) and
|
|
89
|
+
# ```` image references, both handled by the
|
|
90
|
+
# shared extractor.
|
|
91
|
+
tables = extract_tables(md, page=1, warnings=warnings)
|
|
92
|
+
images = extract_images(md, page=1, warnings=warnings, include_html_img=True)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
page = Page(text=md, number=1, tables=tables, images=images)
|
|
96
|
+
meta = Meta(
|
|
97
|
+
format="docx",
|
|
98
|
+
pages=1,
|
|
99
|
+
size=len(data),
|
|
100
|
+
title=title,
|
|
101
|
+
author=author,
|
|
102
|
+
created_at=created_at,
|
|
103
|
+
reader=self.name,
|
|
104
|
+
warnings=warnings,
|
|
105
|
+
)
|
|
106
|
+
return Document(pages=[page], meta=meta)
|
|
107
|
+
except Exception as e: # pragma: no cover - defensive
|
|
108
|
+
raise ParseError(f"docx reader failed to build Document: {e}") from e
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_core_props(data: bytes, warnings: list[str]):
|
|
112
|
+
"""Best-effort read of DOCX core properties via python-docx."""
|
|
113
|
+
try:
|
|
114
|
+
from docx import Document as _DocxDoc
|
|
115
|
+
except ImportError:
|
|
116
|
+
warnings.append("python-docx not installed; skipping DOCX metadata")
|
|
117
|
+
return None, None, None
|
|
118
|
+
try:
|
|
119
|
+
d = _DocxDoc(io.BytesIO(data))
|
|
120
|
+
props = d.core_properties
|
|
121
|
+
title = (props.title or None) or None
|
|
122
|
+
author = (props.author or None) or None
|
|
123
|
+
created_at = props.created
|
|
124
|
+
# Naive datetimes: stamp as UTC so Meta.created_at is always tz-aware.
|
|
125
|
+
if created_at is not None and created_at.tzinfo is None:
|
|
126
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
127
|
+
return title, author, created_at
|
|
128
|
+
except Exception as e:
|
|
129
|
+
warnings.append(f"DOCX metadata read failed: {e}")
|
|
130
|
+
return None, None, None
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""HTML reader — uses ``markdownify`` for HTML → Markdown conversion.
|
|
2
|
+
|
|
3
|
+
File name inside the subpackage is the *core driver library* (``markdownify``);
|
|
4
|
+
``beautifulsoup4`` is a pre-processor (strip ``<head>``, read ``<title>``).
|
|
5
|
+
"""
|
|
6
|
+
from . import markdownify # noqa: F401
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""HTML reader backed by ``markdownify``.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Decode bytes (UTF-8 BOM-aware; latin-1 fallback via the plaintext helper).
|
|
5
|
+
- Strip ``<head>`` before conversion. ``markdownify`` otherwise inlines
|
|
6
|
+
``<title>`` / ``<meta>`` text into the body output, which pollutes the
|
|
7
|
+
Markdown. We use BeautifulSoup to remove the head cleanly (and to pull
|
|
8
|
+
``<title>`` out separately for ``meta.title``).
|
|
9
|
+
- Convert the body to Markdown via ``markdownify`` with ``heading_style="ATX"``
|
|
10
|
+
(the ``# Heading`` form, which is the dialect we normalise on everywhere).
|
|
11
|
+
- Structural extraction (tables, images) is delegated to the shared
|
|
12
|
+
``_md_structure`` module, so HTML, DOCX and Markdown readers all present
|
|
13
|
+
a consistent ``doc.tables`` / ``doc.images`` surface.
|
|
14
|
+
|
|
15
|
+
File naming rule: ``markdownify.py`` — the core driver for HTML → Markdown.
|
|
16
|
+
BeautifulSoup is a pre-processor (strip head, read title) and therefore
|
|
17
|
+
does not determine the file name.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ..base import Reader
|
|
26
|
+
from ..._core.document import Document, Meta, Page
|
|
27
|
+
from ...errors import ParseError
|
|
28
|
+
from .._md_structure import extract_images, extract_tables
|
|
29
|
+
from ..text.stdlib import _decode_text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HtmlReader(Reader):
|
|
33
|
+
name = "markdownify"
|
|
34
|
+
formats = ("html",)
|
|
35
|
+
is_default = True
|
|
36
|
+
|
|
37
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
38
|
+
try:
|
|
39
|
+
from markdownify import markdownify as _to_md
|
|
40
|
+
except ImportError as e:
|
|
41
|
+
raise ParseError(
|
|
42
|
+
"markdownify is required for HTML parsing: pip install markdownify"
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
warnings: list[str] = []
|
|
46
|
+
html, decode_warning = _decode_text(data)
|
|
47
|
+
if decode_warning:
|
|
48
|
+
warnings.append(decode_warning)
|
|
49
|
+
|
|
50
|
+
# Extract <title> and strip <head> so markdownify does not leak head
|
|
51
|
+
# contents into the body.
|
|
52
|
+
title, body_html = _prepare_html(html, warnings)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
md = _to_md(body_html, heading_style="ATX")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise ParseError(f"markdownify failed: {e}") from e
|
|
58
|
+
|
|
59
|
+
if not title and source_name:
|
|
60
|
+
title = Path(source_name).stem or None
|
|
61
|
+
|
|
62
|
+
tables = extract_tables(md, page=1, warnings=warnings)
|
|
63
|
+
images = extract_images(md, page=1, warnings=warnings, include_html_img=True)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
page = Page(text=md, number=1, tables=tables, images=images)
|
|
67
|
+
meta = Meta(
|
|
68
|
+
format="html",
|
|
69
|
+
pages=1,
|
|
70
|
+
size=len(data),
|
|
71
|
+
title=title,
|
|
72
|
+
reader=self.name,
|
|
73
|
+
created_at=datetime.now(timezone.utc),
|
|
74
|
+
warnings=warnings,
|
|
75
|
+
)
|
|
76
|
+
return Document(pages=[page], meta=meta)
|
|
77
|
+
except Exception as e: # pragma: no cover - defensive
|
|
78
|
+
raise ParseError(f"html reader failed to build Document: {e}") from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _prepare_html(html: str, warnings: list[str]) -> tuple[Optional[str], str]:
|
|
82
|
+
"""Return ``(title, body_html)`` — ``title`` may be ``None``.
|
|
83
|
+
|
|
84
|
+
Uses BeautifulSoup if available, otherwise degrades to returning the raw
|
|
85
|
+
HTML and no title (with a warning). Never raises.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
from bs4 import BeautifulSoup
|
|
89
|
+
except ImportError:
|
|
90
|
+
warnings.append("beautifulsoup4 not installed; skipping <head> removal")
|
|
91
|
+
return None, html
|
|
92
|
+
try:
|
|
93
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
94
|
+
# Pull the title before removing <head>.
|
|
95
|
+
title: Optional[str] = None
|
|
96
|
+
if soup.title and soup.title.string:
|
|
97
|
+
s = soup.title.string.strip()
|
|
98
|
+
title = s or None
|
|
99
|
+
# Remove <head> entirely so its contents don't end up in the body
|
|
100
|
+
# Markdown. Remove stray <script> / <style> too, since markdownify
|
|
101
|
+
# will otherwise emit their raw contents as text.
|
|
102
|
+
if soup.head is not None:
|
|
103
|
+
soup.head.decompose()
|
|
104
|
+
for tag in soup.find_all(["script", "style", "noscript"]):
|
|
105
|
+
tag.decompose()
|
|
106
|
+
# Prefer the body subtree if present; otherwise fall back to the
|
|
107
|
+
# whole (now head-less) document.
|
|
108
|
+
body = soup.body
|
|
109
|
+
body_html = str(body) if body is not None else str(soup)
|
|
110
|
+
return title, body_html
|
|
111
|
+
except Exception as e:
|
|
112
|
+
warnings.append(f"HTML pre-processing failed: {e}")
|
|
113
|
+
return None, html
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Image reader.
|
|
2
|
+
|
|
3
|
+
Multimodal LLMs (GPT-4o, Claude 3.5, Gemini, etc.) consume images via
|
|
4
|
+
base64 ``data:`` URLs or direct URLs. fyle therefore does the minimum
|
|
5
|
+
useful transformation: wrap the raw bytes into a ``data:`` URL and expose
|
|
6
|
+
them through both ``doc.text`` (as a Markdown image token) and
|
|
7
|
+
``doc.images`` so the caller can feed the document into a prompt directly.
|
|
8
|
+
|
|
9
|
+
fyle does not perform OCR. If you want text extracted from an image, run
|
|
10
|
+
an OCR / VLM pipeline on ``doc.images[0].data`` yourself.
|
|
11
|
+
|
|
12
|
+
File naming rule: ``stdlib.py`` \u2014 the core driver is Python's standard
|
|
13
|
+
library (``base64`` + ``mimetypes``). Pillow is optional and only used
|
|
14
|
+
for non-fatal metadata (dimensions), not for decoding.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from . import stdlib # noqa: F401
|