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,133 @@
1
+ import csv
2
+ import io
3
+ import re
4
+ from typing import BinaryIO, Any
5
+ from charset_normalizer import from_bytes
6
+ from .._base_converter import DocumentConverter, DocumentConverterResult
7
+ from .._stream_info import StreamInfo
8
+
9
+ ACCEPTED_MIME_TYPE_PREFIXES = [
10
+ "text/csv",
11
+ "application/csv",
12
+ ]
13
+ ACCEPTED_FILE_EXTENSIONS = [".csv"]
14
+
15
+
16
+ # Matches a pipe together with the (possibly empty) run of backslashes in front
17
+ # of it, so that run can be doubled before the pipe is escaped.
18
+ # The lookbehind avoids retrying from each position inside a backslash run.
19
+ _PIPE_ESCAPE_RE = re.compile(r"(?<!\\)(\\*)\|")
20
+
21
+
22
+ def _escape_table_cell(value: str) -> str:
23
+ r"""Escape a CSV value so it is safe inside a Markdown table cell.
24
+
25
+ A pipe is a column separator, so it must be escaped.
26
+ Line breaks would end the row early, so they collapse to a single space.
27
+ """
28
+ value = _PIPE_ESCAPE_RE.sub(lambda m: m.group(1) * 2 + r"\|", value)
29
+ return value.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
30
+
31
+
32
+ def _trim_outer_blank_rows(rows: list[list[str]]) -> None:
33
+ """Remove empty rows from the beginning and end, and immediately after the header. This operation is performed in-place."""
34
+ start = 0
35
+ while start < len(rows) and not rows[start]:
36
+ start += 1
37
+
38
+ if start == len(rows):
39
+ rows.clear()
40
+ return
41
+
42
+ header_index = start
43
+ start += 1
44
+ while start < len(rows) and not rows[start]:
45
+ start += 1
46
+
47
+ end = len(rows)
48
+ while end > start and not rows[end - 1]:
49
+ end -= 1
50
+
51
+ # Remove each blank run at once, rather than shifting the list per row.
52
+ del rows[end:]
53
+ del rows[header_index + 1 : start]
54
+ del rows[:header_index]
55
+
56
+
57
+ class CsvConverter(DocumentConverter):
58
+ """
59
+ Converts CSV files to Markdown tables.
60
+ """
61
+
62
+ def __init__(self):
63
+ super().__init__()
64
+
65
+ def accepts(
66
+ self,
67
+ file_stream: BinaryIO,
68
+ stream_info: StreamInfo,
69
+ **kwargs: Any, # Options to pass to the converter
70
+ ) -> bool:
71
+ mimetype = (stream_info.mimetype or "").lower()
72
+ extension = (stream_info.extension or "").lower()
73
+ if extension in ACCEPTED_FILE_EXTENSIONS:
74
+ return True
75
+ for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
76
+ if mimetype.startswith(prefix):
77
+ return True
78
+ return False
79
+
80
+ def convert(
81
+ self,
82
+ file_stream: BinaryIO,
83
+ stream_info: StreamInfo,
84
+ **kwargs: Any, # Options to pass to the converter
85
+ ) -> DocumentConverterResult:
86
+ # Read the file content
87
+ if stream_info.charset:
88
+ content = file_stream.read().decode(stream_info.charset)
89
+ else:
90
+ data = file_stream.read()
91
+ detected = from_bytes(data).best()
92
+ content = (
93
+ str(detected)
94
+ if detected is not None
95
+ else data.decode("utf-8", errors="ignore")
96
+ )
97
+
98
+ # Excel and other tools prepend a UTF-8 BOM to CSV exports; strip it so
99
+ # it does not end up inside the first header cell.
100
+ content = content.lstrip("\ufeff")
101
+
102
+ # Parse CSV content
103
+ reader = csv.reader(io.StringIO(content, newline=""))
104
+ rows = list(reader)
105
+ _trim_outer_blank_rows(rows)
106
+
107
+ if not rows:
108
+ return DocumentConverterResult(markdown="")
109
+
110
+ # Pad all rows, including the header, to preserve the widest row.
111
+ num_columns = max(len(row) for row in rows)
112
+ for row in rows:
113
+ row.extend([""] * (num_columns - len(row)))
114
+
115
+ # Create markdown table
116
+ markdown_table = []
117
+
118
+ # Add header row
119
+ header = [_escape_table_cell(cell) for cell in rows[0]]
120
+ markdown_table.append("| " + " | ".join(header) + " |")
121
+
122
+ # Add separator row
123
+ markdown_table.append("| " + " | ".join(["---"] * num_columns) + " |")
124
+
125
+ # Add data rows
126
+ for row in rows[1:]:
127
+ markdown_table.append(
128
+ "| " + " | ".join(_escape_table_cell(cell) for cell in row) + " |"
129
+ )
130
+
131
+ result = "\n".join(markdown_table)
132
+
133
+ return DocumentConverterResult(markdown=result)
@@ -0,0 +1,570 @@
1
+ """Azure Content Understanding converter for MarkItDown.
2
+
3
+ Converts files using Azure Content Understanding (CU) for high-quality,
4
+ multi-modal extraction with structured field output. Supports documents,
5
+ images, audio, and video. Fields are serialized as YAML front matter via
6
+ the CU SDK's ``to_llm_input()`` helper.
7
+
8
+ Install dependencies: ``pip install 'markitdown-gc[az-content-understanding]'``
9
+ """
10
+
11
+ import sys
12
+ import os
13
+ from typing import BinaryIO, Any, List, Optional, Dict
14
+ from enum import Enum
15
+
16
+ from .._base_converter import DocumentConverter, DocumentConverterResult
17
+ from .._stream_info import StreamInfo
18
+ from .._exceptions import MissingDependencyException
19
+
20
+ # Try loading optional dependencies — save error for later
21
+ _dependency_exc_info = None
22
+ try:
23
+ from azure.ai.contentunderstanding import ContentUnderstandingClient, to_llm_input
24
+ from azure.core.credentials import AzureKeyCredential, TokenCredential
25
+ from azure.core.pipeline.policies import UserAgentPolicy
26
+ from azure.identity import DefaultAzureCredential
27
+ except ImportError:
28
+ _dependency_exc_info = sys.exc_info()
29
+
30
+ # Stub classes for type hinting
31
+ class AzureKeyCredential: # type: ignore[no-redef]
32
+ pass
33
+
34
+ class TokenCredential: # type: ignore[no-redef]
35
+ pass
36
+
37
+ class ContentUnderstandingClient: # type: ignore[no-redef]
38
+ pass
39
+
40
+ class UserAgentPolicy: # type: ignore[no-redef]
41
+ pass
42
+
43
+ class DefaultAzureCredential: # type: ignore[no-redef]
44
+ pass
45
+
46
+ def to_llm_input(*args, **kwargs): # type: ignore[no-redef]
47
+ pass
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # File type enum and routing tables
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ class ContentUnderstandingFileType(str, Enum):
56
+ """Supported file types for Content Understanding conversion."""
57
+
58
+ # Documents
59
+ PDF = "pdf"
60
+ DOCX = "docx"
61
+ PPTX = "pptx"
62
+ XLSX = "xlsx"
63
+ HTML = "html"
64
+ TXT = "txt"
65
+ MD = "md"
66
+ RTF = "rtf"
67
+ XML = "xml"
68
+
69
+ # Email
70
+ EML = "eml"
71
+ MSG = "msg"
72
+
73
+ # Images (document modality)
74
+ JPEG = "jpeg"
75
+ PNG = "png"
76
+ BMP = "bmp"
77
+ TIFF = "tiff"
78
+ HEIF = "heif"
79
+
80
+ # Video
81
+ MP4 = "mp4"
82
+ M4V = "m4v"
83
+ MOV = "mov"
84
+ AVI = "avi"
85
+ MKV = "mkv"
86
+ WEBM = "webm"
87
+ FLV = "flv"
88
+ WMV = "wmv"
89
+
90
+ # Audio
91
+ WAV = "wav"
92
+ MP3 = "mp3"
93
+ M4A = "m4a"
94
+ FLAC = "flac"
95
+ OGG = "ogg"
96
+ AAC = "aac"
97
+ WMA = "wma"
98
+
99
+
100
+ # Extension → file type
101
+ _EXTENSION_MAP: Dict[str, ContentUnderstandingFileType] = {
102
+ # Documents
103
+ ".pdf": ContentUnderstandingFileType.PDF,
104
+ ".docx": ContentUnderstandingFileType.DOCX,
105
+ ".pptx": ContentUnderstandingFileType.PPTX,
106
+ ".xlsx": ContentUnderstandingFileType.XLSX,
107
+ ".html": ContentUnderstandingFileType.HTML,
108
+ ".txt": ContentUnderstandingFileType.TXT,
109
+ ".md": ContentUnderstandingFileType.MD,
110
+ ".rtf": ContentUnderstandingFileType.RTF,
111
+ ".xml": ContentUnderstandingFileType.XML,
112
+ # Email
113
+ ".eml": ContentUnderstandingFileType.EML,
114
+ ".msg": ContentUnderstandingFileType.MSG,
115
+ # Images
116
+ ".jpg": ContentUnderstandingFileType.JPEG,
117
+ ".jpeg": ContentUnderstandingFileType.JPEG,
118
+ ".jpe": ContentUnderstandingFileType.JPEG,
119
+ ".png": ContentUnderstandingFileType.PNG,
120
+ ".bmp": ContentUnderstandingFileType.BMP,
121
+ ".tiff": ContentUnderstandingFileType.TIFF,
122
+ ".heif": ContentUnderstandingFileType.HEIF,
123
+ ".heic": ContentUnderstandingFileType.HEIF,
124
+ # Video
125
+ ".mp4": ContentUnderstandingFileType.MP4,
126
+ ".m4v": ContentUnderstandingFileType.M4V,
127
+ ".mov": ContentUnderstandingFileType.MOV,
128
+ ".avi": ContentUnderstandingFileType.AVI,
129
+ ".mkv": ContentUnderstandingFileType.MKV,
130
+ ".webm": ContentUnderstandingFileType.WEBM,
131
+ ".flv": ContentUnderstandingFileType.FLV,
132
+ ".wmv": ContentUnderstandingFileType.WMV,
133
+ # Audio
134
+ ".wav": ContentUnderstandingFileType.WAV,
135
+ ".mp3": ContentUnderstandingFileType.MP3,
136
+ ".m4a": ContentUnderstandingFileType.M4A,
137
+ ".flac": ContentUnderstandingFileType.FLAC,
138
+ ".ogg": ContentUnderstandingFileType.OGG,
139
+ ".aac": ContentUnderstandingFileType.AAC,
140
+ ".wma": ContentUnderstandingFileType.WMA,
141
+ }
142
+
143
+ # MIME type prefixes for each file type
144
+ _MIME_PREFIXES: Dict[ContentUnderstandingFileType, List[str]] = {
145
+ # Documents
146
+ ContentUnderstandingFileType.PDF: ["application/pdf", "application/x-pdf"],
147
+ ContentUnderstandingFileType.DOCX: [
148
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
149
+ ],
150
+ ContentUnderstandingFileType.PPTX: [
151
+ "application/vnd.openxmlformats-officedocument.presentationml"
152
+ ],
153
+ ContentUnderstandingFileType.XLSX: [
154
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
155
+ ],
156
+ ContentUnderstandingFileType.HTML: ["text/html", "application/xhtml+xml"],
157
+ ContentUnderstandingFileType.TXT: ["text/plain"],
158
+ ContentUnderstandingFileType.MD: ["text/markdown"],
159
+ ContentUnderstandingFileType.RTF: ["text/rtf", "application/rtf"],
160
+ ContentUnderstandingFileType.XML: ["text/xml", "application/xml"],
161
+ # Email
162
+ ContentUnderstandingFileType.EML: ["message/rfc822"],
163
+ ContentUnderstandingFileType.MSG: ["application/vnd.ms-outlook"],
164
+ # Images
165
+ ContentUnderstandingFileType.JPEG: ["image/jpeg"],
166
+ ContentUnderstandingFileType.PNG: ["image/png"],
167
+ ContentUnderstandingFileType.BMP: ["image/bmp"],
168
+ ContentUnderstandingFileType.TIFF: ["image/tiff"],
169
+ ContentUnderstandingFileType.HEIF: ["image/heif", "image/heic"],
170
+ # Video
171
+ ContentUnderstandingFileType.MP4: ["video/mp4"],
172
+ ContentUnderstandingFileType.M4V: ["video/x-m4v"],
173
+ ContentUnderstandingFileType.MOV: ["video/quicktime"],
174
+ ContentUnderstandingFileType.AVI: ["video/x-msvideo"],
175
+ ContentUnderstandingFileType.MKV: ["video/x-matroska"],
176
+ ContentUnderstandingFileType.WEBM: ["video/webm"],
177
+ ContentUnderstandingFileType.FLV: ["video/x-flv"],
178
+ ContentUnderstandingFileType.WMV: ["video/x-ms-wmv"],
179
+ # Audio
180
+ ContentUnderstandingFileType.WAV: ["audio/wav", "audio/x-wav"],
181
+ ContentUnderstandingFileType.MP3: ["audio/mpeg", "audio/mp3"],
182
+ ContentUnderstandingFileType.M4A: ["audio/mp4", "audio/m4a", "audio/x-m4a"],
183
+ ContentUnderstandingFileType.FLAC: ["audio/flac", "audio/x-flac"],
184
+ ContentUnderstandingFileType.OGG: ["audio/ogg"],
185
+ ContentUnderstandingFileType.AAC: ["audio/aac"],
186
+ ContentUnderstandingFileType.WMA: ["audio/x-ms-wma"],
187
+ }
188
+
189
+ _MIME_ALIASES: Dict[str, str] = {
190
+ "audio/x-wav": "audio/wav",
191
+ "audio/x-flac": "audio/flac",
192
+ "audio/x-m4a": "audio/mp4",
193
+ "video/x-m4v": "video/mp4",
194
+ }
195
+
196
+ # File type → modality category
197
+ _DOCUMENT_TYPES = {
198
+ ContentUnderstandingFileType.PDF,
199
+ ContentUnderstandingFileType.DOCX,
200
+ ContentUnderstandingFileType.PPTX,
201
+ ContentUnderstandingFileType.XLSX,
202
+ ContentUnderstandingFileType.HTML,
203
+ ContentUnderstandingFileType.TXT,
204
+ ContentUnderstandingFileType.MD,
205
+ ContentUnderstandingFileType.RTF,
206
+ ContentUnderstandingFileType.XML,
207
+ ContentUnderstandingFileType.EML,
208
+ ContentUnderstandingFileType.MSG,
209
+ }
210
+
211
+ _IMAGE_TYPES = {
212
+ ContentUnderstandingFileType.JPEG,
213
+ ContentUnderstandingFileType.PNG,
214
+ ContentUnderstandingFileType.BMP,
215
+ ContentUnderstandingFileType.TIFF,
216
+ ContentUnderstandingFileType.HEIF,
217
+ }
218
+
219
+ _VIDEO_TYPES = {
220
+ ContentUnderstandingFileType.MP4,
221
+ ContentUnderstandingFileType.M4V,
222
+ ContentUnderstandingFileType.MOV,
223
+ ContentUnderstandingFileType.AVI,
224
+ ContentUnderstandingFileType.MKV,
225
+ ContentUnderstandingFileType.WEBM,
226
+ ContentUnderstandingFileType.FLV,
227
+ ContentUnderstandingFileType.WMV,
228
+ }
229
+
230
+ _AUDIO_TYPES = {
231
+ ContentUnderstandingFileType.WAV,
232
+ ContentUnderstandingFileType.MP3,
233
+ ContentUnderstandingFileType.M4A,
234
+ ContentUnderstandingFileType.FLAC,
235
+ ContentUnderstandingFileType.OGG,
236
+ ContentUnderstandingFileType.AAC,
237
+ ContentUnderstandingFileType.WMA,
238
+ }
239
+
240
+ _PREBUILT_ANALYZERS = {
241
+ "document": "prebuilt-documentSearch",
242
+ "image": "prebuilt-documentSearch",
243
+ "video": "prebuilt-videoSearch",
244
+ "audio": "prebuilt-audioSearch",
245
+ }
246
+
247
+ # All supported file types (default set when file_types is None)
248
+ _ALL_FILE_TYPES = list(ContentUnderstandingFileType)
249
+
250
+
251
+ def _get_modality(file_type: ContentUnderstandingFileType) -> str:
252
+ """Get the modality category for a file type."""
253
+ if file_type in _DOCUMENT_TYPES:
254
+ return "document"
255
+ elif file_type in _IMAGE_TYPES:
256
+ return "image"
257
+ elif file_type in _VIDEO_TYPES:
258
+ return "video"
259
+ elif file_type in _AUDIO_TYPES:
260
+ return "audio"
261
+ raise ValueError(f"Unknown file type: {file_type}")
262
+
263
+
264
+ def _detect_file_type(
265
+ stream_info: StreamInfo,
266
+ file_types: Optional[List[ContentUnderstandingFileType]] = None,
267
+ ) -> Optional[ContentUnderstandingFileType]:
268
+ """Detect a supported CU file type from extension or MIME type."""
269
+ allowed = set(file_types) if file_types is not None else None
270
+
271
+ extension = (stream_info.extension or "").lower()
272
+ file_type = _EXTENSION_MAP.get(extension)
273
+ if file_type is not None and (allowed is None or file_type in allowed):
274
+ return file_type
275
+
276
+ mimetype = _clean_mime_type(stream_info.mimetype)
277
+ if not mimetype:
278
+ return None
279
+
280
+ return _detect_file_type_from_mime(mimetype, allowed)
281
+
282
+
283
+ def _clean_mime_type(mimetype: Optional[str]) -> str:
284
+ return (mimetype or "").split(";", 1)[0].strip().lower()
285
+
286
+
287
+ def _canonical_mime_type(mimetype: Optional[str]) -> str:
288
+ cleaned = _clean_mime_type(mimetype)
289
+ return _MIME_ALIASES.get(cleaned, cleaned) or "application/octet-stream"
290
+
291
+
292
+ def _content_type_for(
293
+ file_type: ContentUnderstandingFileType,
294
+ mimetype: Optional[str],
295
+ ) -> str:
296
+ """Resolve the content type to send to the CU API.
297
+
298
+ Uses the resolved ``file_type`` as the source of truth so analyzer
299
+ routing and payload metadata stay consistent. The caller-provided
300
+ ``mimetype`` is only used when it is consistent with ``file_type``
301
+ (e.g., to preserve subtype distinctions like ``image/heic`` vs
302
+ ``image/heif``). When ``mimetype`` disagrees with the resolved
303
+ ``file_type`` (e.g., ``.pdf`` extension with ``audio/mpeg``
304
+ mimetype), the canonical MIME type for ``file_type`` is used.
305
+ """
306
+ prefixes = _MIME_PREFIXES.get(file_type, [])
307
+ canonical = _canonical_mime_type(mimetype)
308
+
309
+ # Use caller-provided MIME if it's consistent with the resolved file_type
310
+ if prefixes and canonical != "application/octet-stream":
311
+ for prefix in prefixes:
312
+ if canonical.startswith(prefix):
313
+ return canonical
314
+
315
+ # Fallback: derive from the resolved file_type (single source of truth)
316
+ if prefixes:
317
+ return _canonical_mime_type(prefixes[0])
318
+
319
+ return canonical
320
+
321
+
322
+ def _detect_file_type_from_mime(
323
+ mimetype: str,
324
+ allowed: Optional[set[ContentUnderstandingFileType]],
325
+ ) -> Optional[ContentUnderstandingFileType]:
326
+ for candidate, prefixes in _MIME_PREFIXES.items():
327
+ if allowed is not None and candidate not in allowed:
328
+ continue
329
+ for prefix in prefixes:
330
+ if mimetype.startswith(prefix):
331
+ return candidate
332
+ return None
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Smart routing: base_analyzer_id → modality mapping
337
+ # ---------------------------------------------------------------------------
338
+
339
+ _BASE_TO_MODALITY: Dict[str, str] = {
340
+ "prebuilt-document": "document",
341
+ "prebuilt-image": "image",
342
+ "prebuilt-audio": "audio",
343
+ "prebuilt-video": "video",
344
+ }
345
+
346
+ # Cache of known prebuilt analyzer name → modality (avoids API call)
347
+ _KNOWN_PREBUILT_MODALITY: Dict[str, str] = {
348
+ # Document-based prebuilts
349
+ "prebuilt-documentSearch": "document",
350
+ "prebuilt-layout": "document",
351
+ "prebuilt-read": "document",
352
+ "prebuilt-document": "document",
353
+ "prebuilt-invoice": "document",
354
+ "prebuilt-receipt": "document",
355
+ "prebuilt-receipt.generic": "document",
356
+ "prebuilt-receipt.hotel": "document",
357
+ "prebuilt-idDocument": "document",
358
+ "prebuilt-idDocument.generic": "document",
359
+ "prebuilt-idDocument.passport": "document",
360
+ "prebuilt-healthInsuranceCard.us": "document",
361
+ "prebuilt-contract": "document",
362
+ "prebuilt-creditCard": "document",
363
+ "prebuilt-creditMemo": "document",
364
+ "prebuilt-bankStatement.us": "document",
365
+ "prebuilt-check.us": "document",
366
+ "prebuilt-purchaseOrder": "document",
367
+ "prebuilt-procurement": "document",
368
+ "prebuilt-payStub.us": "document",
369
+ "prebuilt-utilityBill": "document",
370
+ "prebuilt-marriageCertificate.us": "document",
371
+ "prebuilt-documentFieldSchema": "document",
372
+ "prebuilt-documentFields": "document",
373
+ # Tax prebuilts (all document-based)
374
+ "prebuilt-tax.us": "document",
375
+ "prebuilt-tax.us.w2": "document",
376
+ "prebuilt-tax.us.w4": "document",
377
+ "prebuilt-tax.us.1040": "document",
378
+ # Mortgage prebuilts
379
+ "prebuilt-mortgage.us": "document",
380
+ "prebuilt-mortgage.us.1003": "document",
381
+ "prebuilt-mortgage.us.closingDisclosure": "document",
382
+ # Image-based prebuilts
383
+ "prebuilt-image": "image",
384
+ "prebuilt-imageSearch": "image",
385
+ # Audio-based prebuilts
386
+ "prebuilt-audio": "audio",
387
+ "prebuilt-audioSearch": "audio",
388
+ "prebuilt-callCenter": "audio",
389
+ # Video-based prebuilts
390
+ "prebuilt-video": "video",
391
+ "prebuilt-videoSearch": "video",
392
+ "prebuilt-videoSynopsis": "video",
393
+ }
394
+
395
+
396
+ def _resolve_analyzer_modality(client: Any, analyzer_id: str) -> str:
397
+ """Resolve analyzer modality from cache or via get_analyzer() fallback.
398
+
399
+ For known prebuilt-* names, returns the modality from
400
+ ``_KNOWN_PREBUILT_MODALITY`` without an API call. For unknown
401
+ prebuilt-* names or custom analyzers, calls ``get_analyzer()``
402
+ to inspect ``base_analyzer_id``.
403
+
404
+ Args:
405
+ client: A ``ContentUnderstandingClient`` instance.
406
+ analyzer_id: The analyzer ID to resolve.
407
+
408
+ Returns:
409
+ Modality string ("document", "image", "audio", or "video").
410
+
411
+ Raises:
412
+ ValueError: If ``get_analyzer()`` fails.
413
+ """
414
+ # Known prebuilt — use cache, no API call
415
+ if analyzer_id in _KNOWN_PREBUILT_MODALITY:
416
+ return _KNOWN_PREBUILT_MODALITY[analyzer_id]
417
+
418
+ # Unknown prebuilt or custom analyzer — call get_analyzer()
419
+ try:
420
+ analyzer_info = client.get_analyzer(analyzer_id)
421
+ except Exception as exc:
422
+ raise ValueError(f"Failed to resolve analyzer '{analyzer_id}': {exc}") from exc
423
+
424
+ if analyzer_info.base_analyzer_id:
425
+ return _BASE_TO_MODALITY.get(analyzer_info.base_analyzer_id, "document")
426
+ return "document"
427
+
428
+
429
+ def _is_analyzer_compatible(file_modality: str, analyzer_modality: str) -> bool:
430
+ """Return True when an analyzer modality can process a file modality."""
431
+ if analyzer_modality == "document":
432
+ return file_modality in {"document", "image"}
433
+ return file_modality == analyzer_modality
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Converter
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ class ContentUnderstandingConverter(DocumentConverter):
442
+ """Converts files using Azure Content Understanding.
443
+
444
+ Provides high-quality document, image, audio, and video conversion
445
+ with structured field extraction via YAML front matter.
446
+ """
447
+
448
+ def __init__(
449
+ self,
450
+ *,
451
+ endpoint: str,
452
+ credential: AzureKeyCredential | TokenCredential | None = None,
453
+ analyzer_id: Optional[str] = None,
454
+ file_types: Optional[List[ContentUnderstandingFileType]] = None,
455
+ ):
456
+ """Initialize the Content Understanding converter.
457
+
458
+ Args:
459
+ endpoint: CU resource endpoint URL.
460
+ credential: Explicit credential. If None, falls back to
461
+ AZURE_API_KEY env var, then DefaultAzureCredential.
462
+ analyzer_id: Custom analyzer for compatible file types.
463
+ When set, the converter checks the analyzer's base modality
464
+ (via get_analyzer() at init) and routes only compatible
465
+ file types to it. Incompatible modalities auto-route to
466
+ default prebuilts. If None, auto-selects by extension/MIME.
467
+ file_types: Which file types to handle. If None, uses the
468
+ default set (all supported formats).
469
+ """
470
+ super().__init__()
471
+
472
+ # Raise if dependencies are missing
473
+ if _dependency_exc_info is not None:
474
+ raise MissingDependencyException(
475
+ "ContentUnderstandingConverter requires the optional dependency "
476
+ "[az-content-understanding] (or [all]) to be installed. "
477
+ "E.g., `pip install 'markitdown-gc[az-content-understanding]'`"
478
+ ) from _dependency_exc_info[
479
+ 1
480
+ ].with_traceback( # type: ignore[union-attr]
481
+ _dependency_exc_info[2]
482
+ )
483
+
484
+ self._file_types = file_types if file_types is not None else _ALL_FILE_TYPES
485
+ self._analyzer_id = analyzer_id
486
+ self._analyzer_modality: Optional[str] = None
487
+
488
+ # Resolve credential
489
+ if credential is None:
490
+ api_key = os.environ.get("AZURE_API_KEY")
491
+ if api_key is not None:
492
+ credential = AzureKeyCredential(api_key)
493
+ else:
494
+ credential = DefaultAzureCredential()
495
+
496
+ # User agent for telemetry
497
+ try:
498
+ from ..__about__ import __version__
499
+ except ImportError:
500
+ __version__ = "unknown"
501
+ user_agent = f"markitdown-cu/{__version__}"
502
+
503
+ # Create CU client
504
+ self._client = ContentUnderstandingClient(
505
+ endpoint=endpoint,
506
+ credential=credential,
507
+ user_agent_policy=UserAgentPolicy(user_agent=user_agent),
508
+ )
509
+
510
+ # Smart routing: resolve analyzer modality at init (at most one API call)
511
+ if self._analyzer_id is not None:
512
+ self._analyzer_modality = _resolve_analyzer_modality(
513
+ self._client, self._analyzer_id
514
+ )
515
+
516
+ def accepts(
517
+ self,
518
+ file_stream: BinaryIO,
519
+ stream_info: StreamInfo,
520
+ **kwargs: Any,
521
+ ) -> bool:
522
+ """Return True if the file type is in the configured set."""
523
+ return _detect_file_type(stream_info, self._file_types) is not None
524
+
525
+ def convert(
526
+ self,
527
+ file_stream: BinaryIO,
528
+ stream_info: StreamInfo,
529
+ **kwargs: Any,
530
+ ) -> DocumentConverterResult:
531
+ """Convert the file using CU and return Markdown with YAML front matter."""
532
+
533
+ # 1. Determine analyzer_id (smart routing: check modality)
534
+ file_type = _detect_file_type(stream_info, self._file_types)
535
+ if file_type is None:
536
+ raise ValueError(
537
+ "Unsupported file type for Content Understanding conversion."
538
+ )
539
+ file_modality = _get_modality(file_type)
540
+
541
+ if (
542
+ self._analyzer_id is not None
543
+ and self._analyzer_modality is not None
544
+ and _is_analyzer_compatible(file_modality, self._analyzer_modality)
545
+ ):
546
+ analyzer_id = self._analyzer_id
547
+ else:
548
+ analyzer_id = _PREBUILT_ANALYZERS.get(
549
+ file_modality, "prebuilt-documentSearch"
550
+ )
551
+
552
+ # 2. Read file bytes and determine MIME type
553
+ file_bytes = file_stream.read()
554
+ content_type = _content_type_for(file_type, stream_info.mimetype)
555
+
556
+ # 3. Call CU SDK
557
+ poller = self._client.begin_analyze_binary(
558
+ analyzer_id=analyzer_id,
559
+ binary_input=file_bytes,
560
+ content_type=content_type,
561
+ )
562
+
563
+ # 4. Block on result
564
+ result = poller.result()
565
+
566
+ # 5. Format output using to_llm_input()
567
+ text = to_llm_input(result)
568
+
569
+ # 6. Return
570
+ return DocumentConverterResult(markdown=text)