markitdown-gc 0.2.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.
- markitdown/GUIDE.md +93 -0
- markitdown/__about__.py +4 -0
- markitdown/__init__.py +34 -0
- markitdown/__main__.py +439 -0
- markitdown/_base_converter.py +109 -0
- markitdown/_exceptions.py +82 -0
- markitdown/_hwp_media.py +132 -0
- markitdown/_markitdown.py +861 -0
- markitdown/_media.py +67 -0
- markitdown/_stream_info.py +32 -0
- markitdown/_uri_utils.py +75 -0
- markitdown/converter_utils/__init__.py +0 -0
- markitdown/converter_utils/_image.py +19 -0
- markitdown/converter_utils/_xlsx_images.py +114 -0
- markitdown/converter_utils/docx/__init__.py +0 -0
- markitdown/converter_utils/docx/_images.py +118 -0
- markitdown/converter_utils/docx/math/__init__.py +0 -0
- markitdown/converter_utils/docx/math/latex_dict.py +291 -0
- markitdown/converter_utils/docx/math/omml.py +417 -0
- markitdown/converter_utils/docx/pre_process.py +290 -0
- markitdown/converters/__init__.py +56 -0
- markitdown/converters/_audio_converter.py +106 -0
- markitdown/converters/_bing_serp_converter.py +120 -0
- markitdown/converters/_csv_converter.py +133 -0
- markitdown/converters/_cu_converter.py +570 -0
- markitdown/converters/_doc_intel_converter.py +258 -0
- markitdown/converters/_docx_converter.py +163 -0
- markitdown/converters/_epub_converter.py +185 -0
- markitdown/converters/_exiftool.py +57 -0
- markitdown/converters/_html_converter.py +112 -0
- markitdown/converters/_hwp_converter.py +105 -0
- markitdown/converters/_image_converter.py +139 -0
- markitdown/converters/_ipynb_converter.py +103 -0
- markitdown/converters/_llm_caption.py +50 -0
- markitdown/converters/_markdownify.py +178 -0
- markitdown/converters/_outlook_msg_converter.py +349 -0
- markitdown/converters/_pdf_converter.py +593 -0
- markitdown/converters/_plain_text_converter.py +66 -0
- markitdown/converters/_pptx_converter.py +387 -0
- markitdown/converters/_rss_converter.py +488 -0
- markitdown/converters/_transcribe_audio.py +60 -0
- markitdown/converters/_wikipedia_converter.py +99 -0
- markitdown/converters/_xlsx_converter.py +279 -0
- markitdown/converters/_youtube_converter.py +256 -0
- markitdown/converters/_zip_converter.py +130 -0
- markitdown/ocr_triage.py +943 -0
- markitdown/py.typed +0 -0
- markitdown_gc-0.2.0.dist-info/METADATA +137 -0
- markitdown_gc-0.2.0.dist-info/RECORD +53 -0
- markitdown_gc-0.2.0.dist-info/WHEEL +4 -0
- markitdown_gc-0.2.0.dist-info/entry_points.txt +2 -0
- markitdown_gc-0.2.0.dist-info/licenses/LICENSE +21 -0
- markitdown_gc-0.2.0.dist-info/licenses/ThirdPartyNotices.md +232 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import shutil
|
|
6
|
+
import traceback
|
|
7
|
+
import io
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from email.message import Message
|
|
10
|
+
from email.utils import collapse_rfc2231_value
|
|
11
|
+
from importlib.metadata import entry_points
|
|
12
|
+
from typing import Any, List, Dict, Optional, Union, BinaryIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
from warnings import warn
|
|
16
|
+
import requests
|
|
17
|
+
import charset_normalizer
|
|
18
|
+
import codecs
|
|
19
|
+
|
|
20
|
+
from ._stream_info import StreamInfo
|
|
21
|
+
from ._uri_utils import parse_data_uri, file_uri_to_path
|
|
22
|
+
|
|
23
|
+
from .converters import (
|
|
24
|
+
PlainTextConverter,
|
|
25
|
+
HtmlConverter,
|
|
26
|
+
RssConverter,
|
|
27
|
+
WikipediaConverter,
|
|
28
|
+
YouTubeConverter,
|
|
29
|
+
IpynbConverter,
|
|
30
|
+
BingSerpConverter,
|
|
31
|
+
PdfConverter,
|
|
32
|
+
DocxConverter,
|
|
33
|
+
XlsxConverter,
|
|
34
|
+
XlsConverter,
|
|
35
|
+
PptxConverter,
|
|
36
|
+
ImageConverter,
|
|
37
|
+
AudioConverter,
|
|
38
|
+
OutlookMsgConverter,
|
|
39
|
+
ZipConverter,
|
|
40
|
+
EpubConverter,
|
|
41
|
+
DocumentIntelligenceConverter,
|
|
42
|
+
ContentUnderstandingConverter,
|
|
43
|
+
CsvConverter,
|
|
44
|
+
HwpConverter,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from ._base_converter import DocumentConverter, DocumentConverterResult
|
|
48
|
+
|
|
49
|
+
from ._exceptions import (
|
|
50
|
+
FileConversionException,
|
|
51
|
+
UnsupportedFormatException,
|
|
52
|
+
FailedConversionAttempt,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_content_disposition_filename(content_disposition: str) -> Optional[str]:
|
|
57
|
+
message = Message()
|
|
58
|
+
message["content-disposition"] = content_disposition
|
|
59
|
+
|
|
60
|
+
fallback_filename: Optional[str] = None
|
|
61
|
+
extended_filename: Optional[str] = None
|
|
62
|
+
for key, value in message.get_params(header="content-disposition", unquote=True):
|
|
63
|
+
if key != "filename":
|
|
64
|
+
continue
|
|
65
|
+
if isinstance(value, tuple):
|
|
66
|
+
extended_filename = collapse_rfc2231_value(value)
|
|
67
|
+
elif fallback_filename is None:
|
|
68
|
+
fallback_filename = value
|
|
69
|
+
|
|
70
|
+
return extended_filename or fallback_filename
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_charset_sample(file_stream: BinaryIO) -> bytes:
|
|
74
|
+
"""Read a 64 KiB sample, completing a trailing split UTF-8 character."""
|
|
75
|
+
sample = file_stream.read(65536)
|
|
76
|
+
if len(sample) < 65536:
|
|
77
|
+
return sample
|
|
78
|
+
|
|
79
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
|
|
80
|
+
try:
|
|
81
|
+
decoder.decode(sample, final=False)
|
|
82
|
+
if not decoder.getstate()[0]:
|
|
83
|
+
return sample
|
|
84
|
+
|
|
85
|
+
suffix = b""
|
|
86
|
+
for _ in range(3):
|
|
87
|
+
chunk = file_stream.read(1)
|
|
88
|
+
if not chunk:
|
|
89
|
+
break
|
|
90
|
+
suffix += chunk
|
|
91
|
+
decoder.decode(chunk, final=False)
|
|
92
|
+
if not decoder.getstate()[0]:
|
|
93
|
+
return sample + suffix
|
|
94
|
+
except UnicodeDecodeError:
|
|
95
|
+
# Leave invalid UTF-8 unchanged for the existing charset detector.
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return sample
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Lower priority values are tried first.
|
|
102
|
+
PRIORITY_SPECIFIC_FILE_FORMAT = (
|
|
103
|
+
0.0 # e.g., .docx, .pdf, .xlsx, Or specific pages, e.g., wikipedia
|
|
104
|
+
)
|
|
105
|
+
PRIORITY_GENERIC_FILE_FORMAT = (
|
|
106
|
+
10.0 # Near catch-all converters for mimetypes like text/*, etc.
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_plugins: Union[None, List[Any]] = None # If None, plugins have not been loaded yet.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_plugins() -> Union[None, List[Any]]:
|
|
114
|
+
"""Lazy load plugins, exiting early if already loaded."""
|
|
115
|
+
global _plugins
|
|
116
|
+
|
|
117
|
+
# Skip if we've already loaded plugins
|
|
118
|
+
if _plugins is not None:
|
|
119
|
+
return _plugins
|
|
120
|
+
|
|
121
|
+
# Load plugins
|
|
122
|
+
_plugins = []
|
|
123
|
+
for entry_point in entry_points(group="markitdown.plugin"):
|
|
124
|
+
try:
|
|
125
|
+
_plugins.append(entry_point.load())
|
|
126
|
+
except Exception:
|
|
127
|
+
tb = traceback.format_exc()
|
|
128
|
+
warn(f"Plugin '{entry_point.name}' failed to load ... skipping:\n{tb}")
|
|
129
|
+
|
|
130
|
+
return _plugins
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(kw_only=True, frozen=True)
|
|
134
|
+
class ConverterRegistration:
|
|
135
|
+
"""A registration of a converter with its priority and other metadata."""
|
|
136
|
+
|
|
137
|
+
converter: DocumentConverter
|
|
138
|
+
priority: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class MarkItDown:
|
|
142
|
+
"""(In preview) An extremely simple text-based document reader, suitable for LLM use.
|
|
143
|
+
This reader will convert common file-types or webpages to Markdown."""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
enable_builtins: Union[None, bool] = None,
|
|
149
|
+
enable_plugins: Union[None, bool] = None,
|
|
150
|
+
**kwargs,
|
|
151
|
+
):
|
|
152
|
+
self._builtins_enabled = False
|
|
153
|
+
self._plugins_enabled = False
|
|
154
|
+
|
|
155
|
+
requests_session = kwargs.get("requests_session")
|
|
156
|
+
if requests_session is None:
|
|
157
|
+
self._requests_session = requests.Session()
|
|
158
|
+
# Signal that we prefer markdown over HTML, etc. if the server supports it.
|
|
159
|
+
# e.g., https://blog.cloudflare.com/markdown-for-agents/
|
|
160
|
+
self._requests_session.headers.update(
|
|
161
|
+
{
|
|
162
|
+
"Accept": "text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1"
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
self._requests_session = requests_session
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
import magika
|
|
170
|
+
except ImportError:
|
|
171
|
+
self._magika = None
|
|
172
|
+
else:
|
|
173
|
+
self._magika = magika.Magika()
|
|
174
|
+
|
|
175
|
+
# TODO - remove these (see enable_builtins)
|
|
176
|
+
self._llm_client: Any = None
|
|
177
|
+
self._llm_model: Union[str | None] = None
|
|
178
|
+
self._llm_prompt: Union[str | None] = None
|
|
179
|
+
self._exiftool_path: Union[str | None] = None
|
|
180
|
+
self._style_map: Union[str | None] = None
|
|
181
|
+
|
|
182
|
+
# Register the converters
|
|
183
|
+
self._converters: List[ConverterRegistration] = []
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
enable_builtins is None or enable_builtins
|
|
187
|
+
): # Default to True when not specified
|
|
188
|
+
self.enable_builtins(**kwargs)
|
|
189
|
+
|
|
190
|
+
if enable_plugins:
|
|
191
|
+
self.enable_plugins(**kwargs)
|
|
192
|
+
|
|
193
|
+
def enable_builtins(self, **kwargs) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Enable and register built-in converters.
|
|
196
|
+
Built-in converters are enabled by default.
|
|
197
|
+
This method should only be called once, if built-ins were initially disabled.
|
|
198
|
+
"""
|
|
199
|
+
if not self._builtins_enabled:
|
|
200
|
+
# TODO: Move these into converter constructors
|
|
201
|
+
self._llm_client = kwargs.get("llm_client")
|
|
202
|
+
self._llm_model = kwargs.get("llm_model")
|
|
203
|
+
self._llm_prompt = kwargs.get("llm_prompt")
|
|
204
|
+
self._exiftool_path = kwargs.get("exiftool_path")
|
|
205
|
+
self._style_map = kwargs.get("style_map")
|
|
206
|
+
|
|
207
|
+
if self._exiftool_path is None:
|
|
208
|
+
self._exiftool_path = os.getenv("EXIFTOOL_PATH")
|
|
209
|
+
|
|
210
|
+
# Still none? Check well-known paths
|
|
211
|
+
if self._exiftool_path is None:
|
|
212
|
+
candidate = shutil.which("exiftool")
|
|
213
|
+
if candidate:
|
|
214
|
+
candidate = os.path.abspath(candidate)
|
|
215
|
+
if any(
|
|
216
|
+
d == os.path.dirname(candidate)
|
|
217
|
+
for d in [
|
|
218
|
+
"/usr/bin",
|
|
219
|
+
"/usr/local/bin",
|
|
220
|
+
"/opt",
|
|
221
|
+
"/opt/bin",
|
|
222
|
+
"/opt/local/bin",
|
|
223
|
+
"/opt/homebrew/bin",
|
|
224
|
+
"C:\\Windows\\System32",
|
|
225
|
+
"C:\\Program Files",
|
|
226
|
+
"C:\\Program Files (x86)",
|
|
227
|
+
]
|
|
228
|
+
):
|
|
229
|
+
self._exiftool_path = candidate
|
|
230
|
+
|
|
231
|
+
# Register converters for successful browsing operations
|
|
232
|
+
# Later registrations are tried first / take higher priority than earlier registrations
|
|
233
|
+
# To this end, the most specific converters should appear below the most generic converters
|
|
234
|
+
self.register_converter(
|
|
235
|
+
PlainTextConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
|
|
236
|
+
)
|
|
237
|
+
self.register_converter(
|
|
238
|
+
ZipConverter(markitdown=self), priority=PRIORITY_GENERIC_FILE_FORMAT
|
|
239
|
+
)
|
|
240
|
+
self.register_converter(
|
|
241
|
+
HtmlConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
|
|
242
|
+
)
|
|
243
|
+
self.register_converter(RssConverter())
|
|
244
|
+
self.register_converter(WikipediaConverter())
|
|
245
|
+
self.register_converter(YouTubeConverter())
|
|
246
|
+
self.register_converter(BingSerpConverter())
|
|
247
|
+
self.register_converter(DocxConverter())
|
|
248
|
+
self.register_converter(XlsxConverter())
|
|
249
|
+
self.register_converter(XlsConverter())
|
|
250
|
+
self.register_converter(PptxConverter())
|
|
251
|
+
self.register_converter(AudioConverter())
|
|
252
|
+
self.register_converter(ImageConverter())
|
|
253
|
+
self.register_converter(IpynbConverter())
|
|
254
|
+
self.register_converter(PdfConverter())
|
|
255
|
+
self.register_converter(OutlookMsgConverter())
|
|
256
|
+
self.register_converter(EpubConverter())
|
|
257
|
+
self.register_converter(CsvConverter())
|
|
258
|
+
self.register_converter(HwpConverter())
|
|
259
|
+
|
|
260
|
+
# Register Document Intelligence converter at the top of the stack if endpoint is provided
|
|
261
|
+
docintel_endpoint = kwargs.get("docintel_endpoint")
|
|
262
|
+
if docintel_endpoint is not None:
|
|
263
|
+
docintel_args: Dict[str, Any] = {}
|
|
264
|
+
docintel_args["endpoint"] = docintel_endpoint
|
|
265
|
+
|
|
266
|
+
docintel_credential = kwargs.get("docintel_credential")
|
|
267
|
+
if docintel_credential is not None:
|
|
268
|
+
docintel_args["credential"] = docintel_credential
|
|
269
|
+
|
|
270
|
+
docintel_types = kwargs.get("docintel_file_types")
|
|
271
|
+
if docintel_types is not None:
|
|
272
|
+
docintel_args["file_types"] = docintel_types
|
|
273
|
+
|
|
274
|
+
docintel_version = kwargs.get("docintel_api_version")
|
|
275
|
+
if docintel_version is not None:
|
|
276
|
+
docintel_args["api_version"] = docintel_version
|
|
277
|
+
|
|
278
|
+
self.register_converter(
|
|
279
|
+
DocumentIntelligenceConverter(**docintel_args),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Register Content Understanding converter at the top of the stack if endpoint is provided
|
|
283
|
+
cu_endpoint = kwargs.get("cu_endpoint")
|
|
284
|
+
if cu_endpoint is not None:
|
|
285
|
+
cu_args: Dict[str, Any] = {}
|
|
286
|
+
cu_args["endpoint"] = cu_endpoint
|
|
287
|
+
|
|
288
|
+
cu_credential = kwargs.get("cu_credential")
|
|
289
|
+
if cu_credential is not None:
|
|
290
|
+
cu_args["credential"] = cu_credential
|
|
291
|
+
|
|
292
|
+
cu_analyzer_id = kwargs.get("cu_analyzer_id")
|
|
293
|
+
if cu_analyzer_id is not None:
|
|
294
|
+
cu_args["analyzer_id"] = cu_analyzer_id
|
|
295
|
+
|
|
296
|
+
cu_file_types = kwargs.get("cu_file_types")
|
|
297
|
+
if cu_file_types is not None:
|
|
298
|
+
cu_args["file_types"] = cu_file_types
|
|
299
|
+
|
|
300
|
+
self.register_converter(
|
|
301
|
+
ContentUnderstandingConverter(**cu_args),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
self._builtins_enabled = True
|
|
305
|
+
else:
|
|
306
|
+
warn("Built-in converters are already enabled.", RuntimeWarning)
|
|
307
|
+
|
|
308
|
+
def enable_plugins(self, **kwargs) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Enable and register converters provided by plugins.
|
|
311
|
+
Plugins are disabled by default.
|
|
312
|
+
This method should only be called once, if plugins were initially disabled.
|
|
313
|
+
"""
|
|
314
|
+
if not self._plugins_enabled:
|
|
315
|
+
# Load plugins
|
|
316
|
+
plugins = _load_plugins()
|
|
317
|
+
assert plugins is not None
|
|
318
|
+
for plugin in plugins:
|
|
319
|
+
try:
|
|
320
|
+
plugin.register_converters(self, **kwargs)
|
|
321
|
+
except Exception:
|
|
322
|
+
tb = traceback.format_exc()
|
|
323
|
+
warn(f"Plugin '{plugin}' failed to register converters:\n{tb}")
|
|
324
|
+
self._plugins_enabled = True
|
|
325
|
+
else:
|
|
326
|
+
warn("Plugin converters are already enabled.", RuntimeWarning)
|
|
327
|
+
|
|
328
|
+
def convert(
|
|
329
|
+
self,
|
|
330
|
+
source: Union[str, requests.Response, Path, BinaryIO],
|
|
331
|
+
*,
|
|
332
|
+
stream_info: Optional[StreamInfo] = None,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> DocumentConverterResult: # TODO: deal with kwargs
|
|
335
|
+
"""
|
|
336
|
+
Args:
|
|
337
|
+
- source: can be a path (str or Path), url, or a requests.response object
|
|
338
|
+
- stream_info: optional stream info to use for the conversion. If None, infer from source
|
|
339
|
+
- kwargs: additional arguments to pass to the converter
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
# Local path or url
|
|
343
|
+
if isinstance(source, str):
|
|
344
|
+
scheme = urlparse(source.strip()).scheme.lower()
|
|
345
|
+
if scheme in ("http", "https", "file", "data"):
|
|
346
|
+
# Rename the url argument to mock_url
|
|
347
|
+
# (Deprecated -- use stream_info)
|
|
348
|
+
_kwargs = {k: v for k, v in kwargs.items()}
|
|
349
|
+
if "url" in _kwargs:
|
|
350
|
+
_kwargs["mock_url"] = _kwargs["url"]
|
|
351
|
+
del _kwargs["url"]
|
|
352
|
+
|
|
353
|
+
return self.convert_uri(source, stream_info=stream_info, **_kwargs)
|
|
354
|
+
else:
|
|
355
|
+
return self.convert_local(source, stream_info=stream_info, **kwargs)
|
|
356
|
+
# Path object
|
|
357
|
+
elif isinstance(source, Path):
|
|
358
|
+
return self.convert_local(source, stream_info=stream_info, **kwargs)
|
|
359
|
+
# Request response
|
|
360
|
+
elif isinstance(source, requests.Response):
|
|
361
|
+
return self.convert_response(source, stream_info=stream_info, **kwargs)
|
|
362
|
+
# Binary stream
|
|
363
|
+
elif (
|
|
364
|
+
hasattr(source, "read")
|
|
365
|
+
and callable(source.read)
|
|
366
|
+
and not isinstance(source, io.TextIOBase)
|
|
367
|
+
):
|
|
368
|
+
return self.convert_stream(source, stream_info=stream_info, **kwargs)
|
|
369
|
+
else:
|
|
370
|
+
raise TypeError(
|
|
371
|
+
f"Invalid source type: {type(source)}. Expected str, requests.Response, BinaryIO."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def convert_local(
|
|
375
|
+
self,
|
|
376
|
+
path: Union[str, Path],
|
|
377
|
+
*,
|
|
378
|
+
stream_info: Optional[StreamInfo] = None,
|
|
379
|
+
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
|
380
|
+
url: Optional[str] = None, # Deprecated -- use stream_info
|
|
381
|
+
**kwargs: Any,
|
|
382
|
+
) -> DocumentConverterResult:
|
|
383
|
+
if isinstance(path, Path):
|
|
384
|
+
path = str(path)
|
|
385
|
+
|
|
386
|
+
# Build a base StreamInfo object from which to start guesses
|
|
387
|
+
base_guess = StreamInfo(
|
|
388
|
+
local_path=path,
|
|
389
|
+
extension=os.path.splitext(path)[1],
|
|
390
|
+
filename=os.path.basename(path),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Extend the base_guess with any additional info from the arguments
|
|
394
|
+
if stream_info is not None:
|
|
395
|
+
base_guess = base_guess.copy_and_update(stream_info)
|
|
396
|
+
|
|
397
|
+
if file_extension is not None:
|
|
398
|
+
# Deprecated -- use stream_info
|
|
399
|
+
base_guess = base_guess.copy_and_update(extension=file_extension)
|
|
400
|
+
|
|
401
|
+
if url is not None:
|
|
402
|
+
# Deprecated -- use stream_info
|
|
403
|
+
base_guess = base_guess.copy_and_update(url=url)
|
|
404
|
+
|
|
405
|
+
with open(path, "rb") as fh:
|
|
406
|
+
guesses = self._get_stream_info_guesses(
|
|
407
|
+
file_stream=fh, base_guess=base_guess
|
|
408
|
+
)
|
|
409
|
+
return self._convert(file_stream=fh, stream_info_guesses=guesses, **kwargs)
|
|
410
|
+
|
|
411
|
+
def convert_stream(
|
|
412
|
+
self,
|
|
413
|
+
stream: BinaryIO,
|
|
414
|
+
*,
|
|
415
|
+
stream_info: Optional[StreamInfo] = None,
|
|
416
|
+
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
|
417
|
+
url: Optional[str] = None, # Deprecated -- use stream_info
|
|
418
|
+
**kwargs: Any,
|
|
419
|
+
) -> DocumentConverterResult:
|
|
420
|
+
guesses: List[StreamInfo] = []
|
|
421
|
+
|
|
422
|
+
# Do we have anything on which to base a guess?
|
|
423
|
+
base_guess = None
|
|
424
|
+
if stream_info is not None or file_extension is not None or url is not None:
|
|
425
|
+
# Start with a non-Null base guess
|
|
426
|
+
if stream_info is None:
|
|
427
|
+
base_guess = StreamInfo()
|
|
428
|
+
else:
|
|
429
|
+
base_guess = stream_info
|
|
430
|
+
|
|
431
|
+
if file_extension is not None:
|
|
432
|
+
# Deprecated -- use stream_info
|
|
433
|
+
assert base_guess is not None # for mypy
|
|
434
|
+
base_guess = base_guess.copy_and_update(extension=file_extension)
|
|
435
|
+
|
|
436
|
+
if url is not None:
|
|
437
|
+
# Deprecated -- use stream_info
|
|
438
|
+
assert base_guess is not None # for mypy
|
|
439
|
+
base_guess = base_guess.copy_and_update(url=url)
|
|
440
|
+
|
|
441
|
+
# Check if we have a seekable stream. If not, load the entire stream into memory.
|
|
442
|
+
if not stream.seekable():
|
|
443
|
+
buffer = io.BytesIO()
|
|
444
|
+
shutil.copyfileobj(stream, buffer)
|
|
445
|
+
buffer.seek(0)
|
|
446
|
+
stream = buffer
|
|
447
|
+
|
|
448
|
+
# Add guesses based on stream content
|
|
449
|
+
guesses = self._get_stream_info_guesses(
|
|
450
|
+
file_stream=stream, base_guess=base_guess or StreamInfo()
|
|
451
|
+
)
|
|
452
|
+
return self._convert(file_stream=stream, stream_info_guesses=guesses, **kwargs)
|
|
453
|
+
|
|
454
|
+
def convert_url(
|
|
455
|
+
self,
|
|
456
|
+
url: str,
|
|
457
|
+
*,
|
|
458
|
+
stream_info: Optional[StreamInfo] = None,
|
|
459
|
+
file_extension: Optional[str] = None,
|
|
460
|
+
mock_url: Optional[str] = None,
|
|
461
|
+
**kwargs: Any,
|
|
462
|
+
) -> DocumentConverterResult:
|
|
463
|
+
"""Alias for convert_uri()"""
|
|
464
|
+
# convert_url will likely be deprecated in the future in favor of convert_uri
|
|
465
|
+
return self.convert_uri(
|
|
466
|
+
url,
|
|
467
|
+
stream_info=stream_info,
|
|
468
|
+
file_extension=file_extension,
|
|
469
|
+
mock_url=mock_url,
|
|
470
|
+
**kwargs,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def convert_uri(
|
|
474
|
+
self,
|
|
475
|
+
uri: str,
|
|
476
|
+
*,
|
|
477
|
+
stream_info: Optional[StreamInfo] = None,
|
|
478
|
+
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
|
479
|
+
mock_url: Optional[
|
|
480
|
+
str
|
|
481
|
+
] = None, # Mock the request as if it came from a different URL
|
|
482
|
+
**kwargs: Any,
|
|
483
|
+
) -> DocumentConverterResult:
|
|
484
|
+
uri = uri.strip()
|
|
485
|
+
scheme = urlparse(uri).scheme.lower()
|
|
486
|
+
|
|
487
|
+
# File URIs
|
|
488
|
+
if scheme == "file":
|
|
489
|
+
netloc, path = file_uri_to_path(uri)
|
|
490
|
+
if netloc and netloc != "localhost":
|
|
491
|
+
raise ValueError(
|
|
492
|
+
f"Unsupported file URI: {uri}. Netloc must be empty or localhost."
|
|
493
|
+
)
|
|
494
|
+
return self.convert_local(
|
|
495
|
+
path,
|
|
496
|
+
stream_info=stream_info,
|
|
497
|
+
file_extension=file_extension,
|
|
498
|
+
url=mock_url,
|
|
499
|
+
**kwargs,
|
|
500
|
+
)
|
|
501
|
+
# Data URIs
|
|
502
|
+
elif scheme == "data":
|
|
503
|
+
mimetype, attributes, data = parse_data_uri(uri)
|
|
504
|
+
|
|
505
|
+
base_guess = StreamInfo(
|
|
506
|
+
mimetype=mimetype,
|
|
507
|
+
charset=attributes.get("charset"),
|
|
508
|
+
)
|
|
509
|
+
if stream_info is not None:
|
|
510
|
+
base_guess = base_guess.copy_and_update(stream_info)
|
|
511
|
+
|
|
512
|
+
return self.convert_stream(
|
|
513
|
+
io.BytesIO(data),
|
|
514
|
+
stream_info=base_guess,
|
|
515
|
+
file_extension=file_extension,
|
|
516
|
+
url=mock_url,
|
|
517
|
+
**kwargs,
|
|
518
|
+
)
|
|
519
|
+
# HTTP/HTTPS URIs
|
|
520
|
+
elif scheme in ("http", "https"):
|
|
521
|
+
response = self._requests_session.get(uri, stream=True)
|
|
522
|
+
response.raise_for_status()
|
|
523
|
+
return self.convert_response(
|
|
524
|
+
response,
|
|
525
|
+
stream_info=stream_info,
|
|
526
|
+
file_extension=file_extension,
|
|
527
|
+
url=mock_url,
|
|
528
|
+
**kwargs,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"Unsupported URI scheme: {uri.split(':')[0]}. Supported schemes are: file:, data:, http:, https:"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
def convert_response(
|
|
536
|
+
self,
|
|
537
|
+
response: requests.Response,
|
|
538
|
+
*,
|
|
539
|
+
stream_info: Optional[StreamInfo] = None,
|
|
540
|
+
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
|
541
|
+
url: Optional[str] = None, # Deprecated -- use stream_info
|
|
542
|
+
**kwargs: Any,
|
|
543
|
+
) -> DocumentConverterResult:
|
|
544
|
+
# If there is a content-type header, get the mimetype and charset (if present)
|
|
545
|
+
mimetype: Optional[str] = None
|
|
546
|
+
charset: Optional[str] = None
|
|
547
|
+
|
|
548
|
+
if "content-type" in response.headers:
|
|
549
|
+
parts = response.headers["content-type"].split(";")
|
|
550
|
+
mimetype = parts.pop(0).strip()
|
|
551
|
+
for part in parts:
|
|
552
|
+
if part.strip().startswith("charset="):
|
|
553
|
+
_charset = part.split("=")[1].strip()
|
|
554
|
+
if len(_charset) > 0:
|
|
555
|
+
charset = _charset
|
|
556
|
+
|
|
557
|
+
# If there is a content-disposition header, get the filename and possibly the extension
|
|
558
|
+
filename: Optional[str] = None
|
|
559
|
+
extension: Optional[str] = None
|
|
560
|
+
if "content-disposition" in response.headers:
|
|
561
|
+
filename = _get_content_disposition_filename(
|
|
562
|
+
response.headers["content-disposition"]
|
|
563
|
+
)
|
|
564
|
+
if filename is not None:
|
|
565
|
+
_, _extension = os.path.splitext(filename)
|
|
566
|
+
if len(_extension) > 0:
|
|
567
|
+
extension = _extension
|
|
568
|
+
|
|
569
|
+
# If there is still no filename, try to read it from the url
|
|
570
|
+
if filename is None:
|
|
571
|
+
parsed_url = urlparse(response.url)
|
|
572
|
+
_, _extension = os.path.splitext(parsed_url.path)
|
|
573
|
+
if len(_extension) > 0: # Looks like this might be a file!
|
|
574
|
+
filename = os.path.basename(parsed_url.path)
|
|
575
|
+
extension = _extension
|
|
576
|
+
|
|
577
|
+
# Create an initial guess from all this information
|
|
578
|
+
base_guess = StreamInfo(
|
|
579
|
+
mimetype=mimetype,
|
|
580
|
+
charset=charset,
|
|
581
|
+
filename=filename,
|
|
582
|
+
extension=extension,
|
|
583
|
+
url=response.url,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Update with any additional info from the arguments
|
|
587
|
+
if stream_info is not None:
|
|
588
|
+
base_guess = base_guess.copy_and_update(stream_info)
|
|
589
|
+
if file_extension is not None:
|
|
590
|
+
# Deprecated -- use stream_info
|
|
591
|
+
base_guess = base_guess.copy_and_update(extension=file_extension)
|
|
592
|
+
if url is not None:
|
|
593
|
+
# Deprecated -- use stream_info
|
|
594
|
+
base_guess = base_guess.copy_and_update(url=url)
|
|
595
|
+
|
|
596
|
+
# Read into BytesIO
|
|
597
|
+
buffer = io.BytesIO()
|
|
598
|
+
for chunk in response.iter_content(chunk_size=65536):
|
|
599
|
+
buffer.write(chunk)
|
|
600
|
+
buffer.seek(0)
|
|
601
|
+
|
|
602
|
+
# Convert
|
|
603
|
+
guesses = self._get_stream_info_guesses(
|
|
604
|
+
file_stream=buffer, base_guess=base_guess
|
|
605
|
+
)
|
|
606
|
+
return self._convert(file_stream=buffer, stream_info_guesses=guesses, **kwargs)
|
|
607
|
+
|
|
608
|
+
def _convert(
|
|
609
|
+
self, *, file_stream: BinaryIO, stream_info_guesses: List[StreamInfo], **kwargs
|
|
610
|
+
) -> DocumentConverterResult:
|
|
611
|
+
from ._media import check_media_options
|
|
612
|
+
|
|
613
|
+
check_media_options(kwargs)
|
|
614
|
+
res: Union[None, DocumentConverterResult] = None
|
|
615
|
+
|
|
616
|
+
# Keep track of which converters throw exceptions
|
|
617
|
+
failed_attempts: List[FailedConversionAttempt] = []
|
|
618
|
+
|
|
619
|
+
# Create a copy of the page_converters list, sorted by priority.
|
|
620
|
+
# We do this with each call to _convert because the priority of converters may change between calls.
|
|
621
|
+
# The sort is guaranteed to be stable, so converters with the same priority will remain in the same order.
|
|
622
|
+
sorted_registrations = sorted(self._converters, key=lambda x: x.priority)
|
|
623
|
+
|
|
624
|
+
# Remember the initial stream position so that we can return to it
|
|
625
|
+
cur_pos = file_stream.tell()
|
|
626
|
+
|
|
627
|
+
for stream_info in stream_info_guesses + [StreamInfo()]:
|
|
628
|
+
for converter_registration in sorted_registrations:
|
|
629
|
+
converter = converter_registration.converter
|
|
630
|
+
# Sanity check -- make sure the cur_pos is still the same
|
|
631
|
+
assert (
|
|
632
|
+
cur_pos == file_stream.tell()
|
|
633
|
+
), "File stream position should NOT change between guess iterations"
|
|
634
|
+
|
|
635
|
+
_kwargs = {k: v for k, v in kwargs.items()}
|
|
636
|
+
|
|
637
|
+
# Copy any additional global options
|
|
638
|
+
if "llm_client" not in _kwargs and self._llm_client is not None:
|
|
639
|
+
_kwargs["llm_client"] = self._llm_client
|
|
640
|
+
|
|
641
|
+
if "llm_model" not in _kwargs and self._llm_model is not None:
|
|
642
|
+
_kwargs["llm_model"] = self._llm_model
|
|
643
|
+
|
|
644
|
+
if "llm_prompt" not in _kwargs and self._llm_prompt is not None:
|
|
645
|
+
_kwargs["llm_prompt"] = self._llm_prompt
|
|
646
|
+
|
|
647
|
+
if "style_map" not in _kwargs and self._style_map is not None:
|
|
648
|
+
_kwargs["style_map"] = self._style_map
|
|
649
|
+
|
|
650
|
+
if "exiftool_path" not in _kwargs and self._exiftool_path is not None:
|
|
651
|
+
_kwargs["exiftool_path"] = self._exiftool_path
|
|
652
|
+
|
|
653
|
+
# Add the list of converters for nested processing
|
|
654
|
+
_kwargs["_parent_converters"] = self._converters
|
|
655
|
+
|
|
656
|
+
# Add legacy kwargs
|
|
657
|
+
if stream_info is not None:
|
|
658
|
+
if stream_info.extension is not None:
|
|
659
|
+
_kwargs["file_extension"] = stream_info.extension
|
|
660
|
+
|
|
661
|
+
if stream_info.url is not None:
|
|
662
|
+
_kwargs["url"] = stream_info.url
|
|
663
|
+
|
|
664
|
+
# Check if the converter will accept the file, and if so, try to convert it
|
|
665
|
+
_accepts = False
|
|
666
|
+
try:
|
|
667
|
+
_accepts = converter.accepts(file_stream, stream_info, **_kwargs)
|
|
668
|
+
except NotImplementedError:
|
|
669
|
+
pass
|
|
670
|
+
|
|
671
|
+
# accept() should not have changed the file stream position
|
|
672
|
+
assert (
|
|
673
|
+
cur_pos == file_stream.tell()
|
|
674
|
+
), f"{type(converter).__name__}.accept() should NOT change the file_stream position"
|
|
675
|
+
|
|
676
|
+
# Attempt the conversion
|
|
677
|
+
if _accepts:
|
|
678
|
+
try:
|
|
679
|
+
res = converter.convert(file_stream, stream_info, **_kwargs)
|
|
680
|
+
except Exception:
|
|
681
|
+
failed_attempts.append(
|
|
682
|
+
FailedConversionAttempt(
|
|
683
|
+
converter=converter, exc_info=sys.exc_info()
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
if kwargs.get("extract_media"):
|
|
687
|
+
raise FileConversionException(attempts=failed_attempts)
|
|
688
|
+
finally:
|
|
689
|
+
file_stream.seek(cur_pos)
|
|
690
|
+
|
|
691
|
+
if res is not None:
|
|
692
|
+
# Normalize the content
|
|
693
|
+
res.text_content = "\n".join(
|
|
694
|
+
[line.rstrip() for line in re.split(r"\r?\n", res.text_content)]
|
|
695
|
+
)
|
|
696
|
+
res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
|
|
697
|
+
return res
|
|
698
|
+
|
|
699
|
+
# If we got this far without success, report any exceptions
|
|
700
|
+
if len(failed_attempts) > 0:
|
|
701
|
+
raise FileConversionException(attempts=failed_attempts)
|
|
702
|
+
|
|
703
|
+
# Nothing can handle it!
|
|
704
|
+
raise UnsupportedFormatException(
|
|
705
|
+
"Could not convert stream to Markdown. No converter attempted a conversion, suggesting that the filetype is simply not supported."
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def register_page_converter(self, converter: DocumentConverter) -> None:
|
|
709
|
+
"""DEPRECATED: Use register_converter instead."""
|
|
710
|
+
warn(
|
|
711
|
+
"register_page_converter is deprecated. Use register_converter instead.",
|
|
712
|
+
DeprecationWarning,
|
|
713
|
+
)
|
|
714
|
+
self.register_converter(converter)
|
|
715
|
+
|
|
716
|
+
def register_converter(
|
|
717
|
+
self,
|
|
718
|
+
converter: DocumentConverter,
|
|
719
|
+
*,
|
|
720
|
+
priority: float = PRIORITY_SPECIFIC_FILE_FORMAT,
|
|
721
|
+
) -> None:
|
|
722
|
+
"""
|
|
723
|
+
Register a DocumentConverter with a given priority.
|
|
724
|
+
|
|
725
|
+
Priorities work as follows: By default, most converters get priority
|
|
726
|
+
PRIORITY_SPECIFIC_FILE_FORMAT (== 0). The exception
|
|
727
|
+
is the PlainTextConverter, HtmlConverter, and ZipConverter, which get
|
|
728
|
+
priority PRIORITY_GENERIC_FILE_FORMAT (== 10), with lower values
|
|
729
|
+
being tried first (i.e., higher priority).
|
|
730
|
+
|
|
731
|
+
Just prior to conversion, the converters are sorted by priority, using
|
|
732
|
+
a stable sort. This means that converters with the same priority will
|
|
733
|
+
remain in the same order, with the most recently registered converters
|
|
734
|
+
appearing first.
|
|
735
|
+
|
|
736
|
+
We have tight control over the order of built-in converters, but
|
|
737
|
+
plugins can register converters in any order. The registration's priority
|
|
738
|
+
field reasserts some control over the order of converters.
|
|
739
|
+
|
|
740
|
+
Plugins can register converters with any priority, to appear before or
|
|
741
|
+
after the built-ins. For example, a plugin with priority 9 will run
|
|
742
|
+
before the PlainTextConverter, but after the built-in converters.
|
|
743
|
+
"""
|
|
744
|
+
self._converters.insert(
|
|
745
|
+
0, ConverterRegistration(converter=converter, priority=priority)
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def _get_stream_info_guesses(
|
|
749
|
+
self, file_stream: BinaryIO, base_guess: StreamInfo
|
|
750
|
+
) -> List[StreamInfo]:
|
|
751
|
+
"""
|
|
752
|
+
Given a base guess, attempt to guess or expand on the stream info using the stream content (via magika).
|
|
753
|
+
"""
|
|
754
|
+
guesses: List[StreamInfo] = []
|
|
755
|
+
|
|
756
|
+
# Enhance the base guess with information based on the extension or mimetype
|
|
757
|
+
enhanced_guess = base_guess.copy_and_update()
|
|
758
|
+
|
|
759
|
+
# If there's an extension and no mimetype, try to guess the mimetype
|
|
760
|
+
if base_guess.mimetype is None and base_guess.extension is not None:
|
|
761
|
+
_m, _ = mimetypes.guess_type(
|
|
762
|
+
"placeholder" + base_guess.extension, strict=False
|
|
763
|
+
)
|
|
764
|
+
if _m is not None:
|
|
765
|
+
enhanced_guess = enhanced_guess.copy_and_update(mimetype=_m)
|
|
766
|
+
|
|
767
|
+
# If there's a mimetype and no extension, try to guess the extension
|
|
768
|
+
if base_guess.mimetype is not None and base_guess.extension is None:
|
|
769
|
+
_e = mimetypes.guess_all_extensions(base_guess.mimetype, strict=False)
|
|
770
|
+
if len(_e) > 0:
|
|
771
|
+
enhanced_guess = enhanced_guess.copy_and_update(extension=_e[0])
|
|
772
|
+
|
|
773
|
+
if self._magika is None:
|
|
774
|
+
return [enhanced_guess]
|
|
775
|
+
|
|
776
|
+
# Call magika to guess from the stream
|
|
777
|
+
cur_pos = file_stream.tell()
|
|
778
|
+
try:
|
|
779
|
+
result = self._magika.identify_stream(file_stream)
|
|
780
|
+
if result.status == "ok" and result.prediction.output.label != "unknown":
|
|
781
|
+
# If it's text, also guess the charset
|
|
782
|
+
charset = None
|
|
783
|
+
if result.prediction.output.is_text:
|
|
784
|
+
# Complete a split UTF-8 character at the sample boundary.
|
|
785
|
+
file_stream.seek(cur_pos)
|
|
786
|
+
stream_page = _read_charset_sample(file_stream)
|
|
787
|
+
charset_result = charset_normalizer.from_bytes(stream_page).best()
|
|
788
|
+
|
|
789
|
+
if charset_result is not None:
|
|
790
|
+
charset = self._normalize_charset(charset_result.encoding)
|
|
791
|
+
|
|
792
|
+
# Normalize the first extension listed
|
|
793
|
+
guessed_extension = None
|
|
794
|
+
if len(result.prediction.output.extensions) > 0:
|
|
795
|
+
guessed_extension = "." + result.prediction.output.extensions[0]
|
|
796
|
+
|
|
797
|
+
# Determine if the guess is compatible with the base guess
|
|
798
|
+
compatible = True
|
|
799
|
+
if (
|
|
800
|
+
base_guess.mimetype is not None
|
|
801
|
+
and base_guess.mimetype != result.prediction.output.mime_type
|
|
802
|
+
):
|
|
803
|
+
compatible = False
|
|
804
|
+
|
|
805
|
+
if (
|
|
806
|
+
base_guess.extension is not None
|
|
807
|
+
and base_guess.extension.lstrip(".")
|
|
808
|
+
not in result.prediction.output.extensions
|
|
809
|
+
):
|
|
810
|
+
compatible = False
|
|
811
|
+
|
|
812
|
+
if (
|
|
813
|
+
base_guess.charset is not None
|
|
814
|
+
and self._normalize_charset(base_guess.charset) != charset
|
|
815
|
+
):
|
|
816
|
+
compatible = False
|
|
817
|
+
|
|
818
|
+
if compatible:
|
|
819
|
+
# Add the compatible base guess
|
|
820
|
+
guesses.append(
|
|
821
|
+
StreamInfo(
|
|
822
|
+
mimetype=base_guess.mimetype
|
|
823
|
+
or result.prediction.output.mime_type,
|
|
824
|
+
extension=base_guess.extension or guessed_extension,
|
|
825
|
+
charset=base_guess.charset or charset,
|
|
826
|
+
filename=base_guess.filename,
|
|
827
|
+
local_path=base_guess.local_path,
|
|
828
|
+
url=base_guess.url,
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
else:
|
|
832
|
+
# The magika guess was incompatible with the base guess, so add both guesses
|
|
833
|
+
guesses.append(enhanced_guess)
|
|
834
|
+
guesses.append(
|
|
835
|
+
StreamInfo(
|
|
836
|
+
mimetype=result.prediction.output.mime_type,
|
|
837
|
+
extension=guessed_extension,
|
|
838
|
+
charset=charset,
|
|
839
|
+
filename=base_guess.filename,
|
|
840
|
+
local_path=base_guess.local_path,
|
|
841
|
+
url=base_guess.url,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
else:
|
|
845
|
+
# There were no other guesses, so just add the base guess
|
|
846
|
+
guesses.append(enhanced_guess)
|
|
847
|
+
finally:
|
|
848
|
+
file_stream.seek(cur_pos)
|
|
849
|
+
|
|
850
|
+
return guesses
|
|
851
|
+
|
|
852
|
+
def _normalize_charset(self, charset: str | None) -> str | None:
|
|
853
|
+
"""
|
|
854
|
+
Normalize a charset string to a canonical form.
|
|
855
|
+
"""
|
|
856
|
+
if charset is None:
|
|
857
|
+
return None
|
|
858
|
+
try:
|
|
859
|
+
return codecs.lookup(charset).name
|
|
860
|
+
except LookupError:
|
|
861
|
+
return charset
|