Skip to content

Commit

Permalink
Merge branch 'main' into feature/llm-description-in-markdown
Browse files Browse the repository at this point in the history
  • Loading branch information
dzemeuksis authored Jan 17, 2025
2 parents ca5a251 + f58a864 commit 1c9a938
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 21 deletions.
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
> [!IMPORTANT]
> (12/19/24) Hello! MarkItDown team members will be resting and recharging with family and friends over the holiday period. Activity/responses on the project may be delayed during the period of Dec 21-Jan 06. We will be excited to engage with you in the new year!
# MarkItDown

[![PyPI](https://img.shields.io/pypi/v/markitdown.svg)](https://pypi.org/project/markitdown/)
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ dependencies = [
"python-pptx",
"pandas",
"openpyxl",
"xlrd",
"pdfminer.six",
"puremagic",
"pydub",
"olefile",
"youtube-transcript-api",
"SpeechRecognition",
"pathvalidate",
Expand Down
166 changes: 154 additions & 12 deletions src/markitdown/_markitdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import mammoth
import markdownify
import olefile
import pandas as pd
import pdfminer
import pdfminer.high_level
Expand All @@ -33,6 +34,7 @@
from charset_normalizer import from_path

# Optional Transcription support
IS_AUDIO_TRANSCRIPTION_CAPABLE = False
try:
# Using warnings' catch_warnings to catch
# pydub's warning of ffmpeg or avconv missing
Expand Down Expand Up @@ -171,7 +173,10 @@ def convert(
# Only accept text files
if content_type is None:
return None
elif "text/" not in content_type.lower():
elif all(
not content_type.lower().startswith(type_prefix)
for type_prefix in ["text/", "application/json"]
):
return None

text_content = str(from_path(local_path).best())
Expand Down Expand Up @@ -724,7 +729,31 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
if extension.lower() != ".xlsx":
return None

sheets = pd.read_excel(local_path, sheet_name=None)
sheets = pd.read_excel(local_path, sheet_name=None, engine="openpyxl")
md_content = ""
for s in sheets:
md_content += f"## {s}\n"
html_content = sheets[s].to_html(index=False)
md_content += self._convert(html_content).text_content.strip() + "\n\n"

return DocumentConverterResult(
title=None,
text_content=md_content.strip(),
)


class XlsConverter(HtmlConverter):
"""
Converts XLS files to Markdown, with each sheet presented as a separate Markdown table.
"""

def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
# Bail if not a XLS
extension = kwargs.get("file_extension", "")
if extension.lower() != ".xls":
return None

sheets = pd.read_excel(local_path, sheet_name=None, engine="xlrd")
md_content = ""
for s in sheets:
md_content += f"## {s}\n"
Expand Down Expand Up @@ -863,14 +892,25 @@ class MediaConverter(DocumentConverter):
Abstract class for multi-modal media (e.g., images and audio)
"""

def _get_metadata(self, local_path):
exiftool = shutil.which("exiftool")
if not exiftool:
def _get_metadata(self, local_path, exiftool_path=None):
if not exiftool_path:
which_exiftool = shutil.which("exiftool")
if which_exiftool:
warn(
f"""Implicit discovery of 'exiftool' is disabled. If you would like to continue to use exiftool in MarkItDown, please set the exiftool_path parameter in the MarkItDown consructor. E.g.,
md = MarkItDown(exiftool_path="{which_exiftool}")
This warning will be removed in future releases.
""",
DeprecationWarning,
)

return None
else:
try:
result = subprocess.run(
[exiftool, "-json", local_path], capture_output=True, text=True
[exiftool_path, "-json", local_path], capture_output=True, text=True
).stdout
return json.loads(result)[0]
except Exception:
Expand All @@ -891,7 +931,7 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
md_content = ""

# Add metadata
metadata = self._get_metadata(local_path)
metadata = self._get_metadata(local_path, kwargs.get("exiftool_path"))
if metadata:
for f in [
"Title",
Expand Down Expand Up @@ -946,7 +986,7 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
md_content = ""

# Add metadata
metadata = self._get_metadata(local_path)
metadata = self._get_metadata(local_path, kwargs.get("exiftool_path"))
if metadata:
for f in [
"Title",
Expand Down Expand Up @@ -1007,7 +1047,7 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
md_content = ""

# Add metadata
metadata = self._get_metadata(local_path)
metadata = self._get_metadata(local_path, kwargs.get("exiftool_path"))
if metadata:
for f in [
"ImageSize",
Expand Down Expand Up @@ -1109,6 +1149,79 @@ def _get_llm_description(self, local_path, extension, client, model, prompt=None
return response.choices[0].message.content


class OutlookMsgConverter(DocumentConverter):
"""Converts Outlook .msg files to markdown by extracting email metadata and content.
Uses the olefile package to parse the .msg file structure and extract:
- Email headers (From, To, Subject)
- Email body content
"""

def convert(
self, local_path: str, **kwargs: Any
) -> Union[None, DocumentConverterResult]:
# Bail if not a MSG file
extension = kwargs.get("file_extension", "")
if extension.lower() != ".msg":
return None

try:
msg = olefile.OleFileIO(local_path)
# Extract email metadata
md_content = "# Email Message\n\n"

# Get headers
headers = {
"From": self._get_stream_data(msg, "__substg1.0_0C1F001F"),
"To": self._get_stream_data(msg, "__substg1.0_0E04001F"),
"Subject": self._get_stream_data(msg, "__substg1.0_0037001F"),
}

# Add headers to markdown
for key, value in headers.items():
if value:
md_content += f"**{key}:** {value}\n"

md_content += "\n## Content\n\n"

# Get email body
body = self._get_stream_data(msg, "__substg1.0_1000001F")
if body:
md_content += body

msg.close()

return DocumentConverterResult(
title=headers.get("Subject"), text_content=md_content.strip()
)

except Exception as e:
raise FileConversionException(
f"Could not convert MSG file '{local_path}': {str(e)}"
)

def _get_stream_data(
self, msg: olefile.OleFileIO, stream_path: str
) -> Union[str, None]:
"""Helper to safely extract and decode stream data from the MSG file."""
try:
if msg.exists(stream_path):
data = msg.openstream(stream_path).read()
# Try UTF-16 first (common for .msg files)
try:
return data.decode("utf-16-le").strip()
except UnicodeDecodeError:
# Fall back to UTF-8
try:
return data.decode("utf-8").strip()
except UnicodeDecodeError:
# Last resort - ignore errors
return data.decode("utf-8", errors="ignore").strip()
except Exception:
pass
return None


class ZipConverter(DocumentConverter):
"""Converts ZIP files to markdown by extracting and converting all contained files.
Expand Down Expand Up @@ -1256,6 +1369,7 @@ def __init__(
llm_client: Optional[Any] = None,
llm_model: Optional[str] = None,
style_map: Optional[str] = None,
exiftool_path: Optional[str] = None,
# Deprecated
mlm_client: Optional[Any] = None,
mlm_model: Optional[str] = None,
Expand All @@ -1265,6 +1379,9 @@ def __init__(
else:
self._requests_session = requests_session

if exiftool_path is None:
exiftool_path = os.environ.get("EXIFTOOL_PATH")

# Handle deprecation notices
#############################
if mlm_client is not None:
Expand Down Expand Up @@ -1297,6 +1414,7 @@ def __init__(
self._llm_client = llm_client
self._llm_model = llm_model
self._style_map = style_map
self._exiftool_path = exiftool_path

self._page_converters: List[DocumentConverter] = []

Expand All @@ -1311,13 +1429,15 @@ def __init__(
self.register_page_converter(BingSerpConverter())
self.register_page_converter(DocxConverter())
self.register_page_converter(XlsxConverter())
self.register_page_converter(XlsConverter())
self.register_page_converter(PptxConverter())
self.register_page_converter(WavConverter())
self.register_page_converter(Mp3Converter())
self.register_page_converter(ImageConverter())
self.register_page_converter(IpynbConverter())
self.register_page_converter(PdfConverter())
self.register_page_converter(ZipConverter())
self.register_page_converter(OutlookMsgConverter())

def convert(
self, source: Union[str, requests.Response, Path], **kwargs: Any
Expand Down Expand Up @@ -1478,12 +1598,15 @@ def _convert(
if "llm_model" not in _kwargs and self._llm_model is not None:
_kwargs["llm_model"] = self._llm_model

# Add the list of converters for nested processing
_kwargs["_parent_converters"] = self._page_converters

if "style_map" not in _kwargs and self._style_map is not None:
_kwargs["style_map"] = self._style_map

if "exiftool_path" not in _kwargs and self._exiftool_path is not None:
_kwargs["exiftool_path"] = self._exiftool_path

# Add the list of converters for nested processing
_kwargs["_parent_converters"] = self._page_converters

# If we hit an error log it and keep trying
try:
res = converter.convert(local_path, **_kwargs)
Expand Down Expand Up @@ -1526,6 +1649,25 @@ def _guess_ext_magic(self, path):
# Use puremagic to guess
try:
guesses = puremagic.magic_file(path)

# Fix for: https://github.com/microsoft/markitdown/issues/222
# If there are no guesses, then try again after trimming leading ASCII whitespaces.
# ASCII whitespace characters are those byte values in the sequence b' \t\n\r\x0b\f'
# (space, tab, newline, carriage return, vertical tab, form feed).
if len(guesses) == 0:
with open(path, "rb") as file:
while True:
char = file.read(1)
if not char: # End of file
break
if not char.isspace():
file.seek(file.tell() - 1)
break
try:
guesses = puremagic.magic_stream(file)
except puremagic.main.PureError:
pass

extensions = list()
for g in guesses:
ext = g.extension.strip()
Expand Down
10 changes: 10 additions & 0 deletions tests/test_files/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"key1": "string_value",
"key2": 1234,
"key3": [
"list_value1",
"list_value2"
],
"5b64c88c-b3c3-4510-bcb8-da0b200602d8": "uuid_key",
"uuid_value": "9700dc99-6685-40b4-9a3a-5e406dcb37f3"
}
Binary file added tests/test_files/test.xls
Binary file not shown.
Binary file added tests/test_files/test_outlook_msg.msg
Binary file not shown.
Loading

0 comments on commit 1c9a938

Please sign in to comment.