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.
Files changed (44) hide show
  1. fyle/__init__.py +46 -0
  2. fyle/_core/__init__.py +5 -0
  3. fyle/_core/api.py +164 -0
  4. fyle/_core/chunking.py +107 -0
  5. fyle/_core/document.py +345 -0
  6. fyle/_core/fetcher.py +68 -0
  7. fyle/_core/registry.py +107 -0
  8. fyle/_core/sniffer.py +251 -0
  9. fyle/_readers/__init__.py +32 -0
  10. fyle/_readers/_md_structure.py +208 -0
  11. fyle/_readers/_whisper.py +126 -0
  12. fyle/_readers/archive/__init__.py +8 -0
  13. fyle/_readers/archive/stdlib.py +513 -0
  14. fyle/_readers/audio/__init__.py +9 -0
  15. fyle/_readers/audio/faster_whisper.py +162 -0
  16. fyle/_readers/base.py +70 -0
  17. fyle/_readers/csv/__init__.py +6 -0
  18. fyle/_readers/csv/stdlib.py +119 -0
  19. fyle/_readers/docx/__init__.py +6 -0
  20. fyle/_readers/docx/mammoth.py +130 -0
  21. fyle/_readers/html/__init__.py +6 -0
  22. fyle/_readers/html/markdownify.py +113 -0
  23. fyle/_readers/image/__init__.py +18 -0
  24. fyle/_readers/image/stdlib.py +136 -0
  25. fyle/_readers/markdown/__init__.py +6 -0
  26. fyle/_readers/markdown/stdlib.py +61 -0
  27. fyle/_readers/pdf/__init__.py +2 -0
  28. fyle/_readers/pdf/pymupdf4llm.py +202 -0
  29. fyle/_readers/pptx/__init__.py +7 -0
  30. fyle/_readers/pptx/python_pptx.py +306 -0
  31. fyle/_readers/sqlite/__init__.py +8 -0
  32. fyle/_readers/sqlite/stdlib.py +366 -0
  33. fyle/_readers/text/__init__.py +7 -0
  34. fyle/_readers/text/stdlib.py +76 -0
  35. fyle/_readers/video/__init__.py +10 -0
  36. fyle/_readers/video/scenedetect.py +330 -0
  37. fyle/_readers/xlsx/__init__.py +6 -0
  38. fyle/_readers/xlsx/openpyxl.py +158 -0
  39. fyle/errors.py +42 -0
  40. fyle/sqlite.py +175 -0
  41. fylepy-0.1.0.dist-info/METADATA +272 -0
  42. fylepy-0.1.0.dist-info/RECORD +44 -0
  43. fylepy-0.1.0.dist-info/WHEEL +4 -0
  44. 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,6 @@
1
+ """CSV reader — renders a CSV as a single Markdown table via ``tabulate``.
2
+
3
+ File name inside the subpackage is the *core driver library* (the Python
4
+ standard library's ``csv`` module); ``tabulate`` is a post-processor.
5
+ """
6
+ from . import stdlib # noqa: F401
@@ -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
+ # ``![alt](data:...)`` 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