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.
Files changed (53) hide show
  1. markitdown/GUIDE.md +93 -0
  2. markitdown/__about__.py +4 -0
  3. markitdown/__init__.py +34 -0
  4. markitdown/__main__.py +439 -0
  5. markitdown/_base_converter.py +109 -0
  6. markitdown/_exceptions.py +82 -0
  7. markitdown/_hwp_media.py +132 -0
  8. markitdown/_markitdown.py +861 -0
  9. markitdown/_media.py +67 -0
  10. markitdown/_stream_info.py +32 -0
  11. markitdown/_uri_utils.py +75 -0
  12. markitdown/converter_utils/__init__.py +0 -0
  13. markitdown/converter_utils/_image.py +19 -0
  14. markitdown/converter_utils/_xlsx_images.py +114 -0
  15. markitdown/converter_utils/docx/__init__.py +0 -0
  16. markitdown/converter_utils/docx/_images.py +118 -0
  17. markitdown/converter_utils/docx/math/__init__.py +0 -0
  18. markitdown/converter_utils/docx/math/latex_dict.py +291 -0
  19. markitdown/converter_utils/docx/math/omml.py +417 -0
  20. markitdown/converter_utils/docx/pre_process.py +290 -0
  21. markitdown/converters/__init__.py +56 -0
  22. markitdown/converters/_audio_converter.py +106 -0
  23. markitdown/converters/_bing_serp_converter.py +120 -0
  24. markitdown/converters/_csv_converter.py +133 -0
  25. markitdown/converters/_cu_converter.py +570 -0
  26. markitdown/converters/_doc_intel_converter.py +258 -0
  27. markitdown/converters/_docx_converter.py +163 -0
  28. markitdown/converters/_epub_converter.py +185 -0
  29. markitdown/converters/_exiftool.py +57 -0
  30. markitdown/converters/_html_converter.py +112 -0
  31. markitdown/converters/_hwp_converter.py +105 -0
  32. markitdown/converters/_image_converter.py +139 -0
  33. markitdown/converters/_ipynb_converter.py +103 -0
  34. markitdown/converters/_llm_caption.py +50 -0
  35. markitdown/converters/_markdownify.py +178 -0
  36. markitdown/converters/_outlook_msg_converter.py +349 -0
  37. markitdown/converters/_pdf_converter.py +593 -0
  38. markitdown/converters/_plain_text_converter.py +66 -0
  39. markitdown/converters/_pptx_converter.py +387 -0
  40. markitdown/converters/_rss_converter.py +488 -0
  41. markitdown/converters/_transcribe_audio.py +60 -0
  42. markitdown/converters/_wikipedia_converter.py +99 -0
  43. markitdown/converters/_xlsx_converter.py +279 -0
  44. markitdown/converters/_youtube_converter.py +256 -0
  45. markitdown/converters/_zip_converter.py +130 -0
  46. markitdown/ocr_triage.py +943 -0
  47. markitdown/py.typed +0 -0
  48. markitdown_gc-0.2.0.dist-info/METADATA +137 -0
  49. markitdown_gc-0.2.0.dist-info/RECORD +53 -0
  50. markitdown_gc-0.2.0.dist-info/WHEEL +4 -0
  51. markitdown_gc-0.2.0.dist-info/entry_points.txt +2 -0
  52. markitdown_gc-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. 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