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,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)
|