Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
gagb authored Dec 16, 2024
2 parents 3548c96 + e7a2e20 commit 83dc811
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 6 deletions.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# MarkItDown

[![PyPI](https://img.shields.io/pypi/v/markitdown.svg)](https://pypi.org/project/markitdown/)

The MarkItDown library is a utility tool for converting various files to Markdown (e.g., for indexing, text analysis, etc.)

It presently supports:
Expand All @@ -12,6 +14,7 @@ It presently supports:
- Audio (EXIF metadata, and speech transcription)
- HTML (special handling of Wikipedia, etc.)
- Various other text-based formats (csv, json, xml, etc.)
- ZIP (Iterates over contents and converts each file)

# Installation

Expand All @@ -27,7 +30,6 @@ or from the source
pip install -e .
```


# Usage
The API is simple:

Expand All @@ -39,6 +41,25 @@ result = markitdown.convert("test.xlsx")
print(result.text_content)
```

To use this as a command-line utility, install it and then run it like this:

```bash
markitdown path-to-file.pdf
```

This will output Markdown to standard output. You can save it like this:

```bash
markitdown path-to-file.pdf > document.md
```

You can pipe content to standard input by omitting the argument:

```bash
cat path-to-file.pdf | markitdown
```


You can also configure markitdown to use Large Language Models to describe images. To do so you must provide mlm_client and mlm_model parameters to MarkItDown object, according to your specific client.

```python
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies = [
"youtube-transcript-api",
"SpeechRecognition",
"pathvalidate",
"charset-normalizer",
]

[project.urls]
Expand Down
172 changes: 167 additions & 5 deletions src/markitdown/_markitdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
import sys
import tempfile
import traceback
import zipfile
from typing import Any, Dict, List, Optional, Union
from urllib.parse import parse_qs, quote, unquote, urlparse, urlunparse
from warnings import catch_warnings

import mammoth
import markdownify
Expand All @@ -26,10 +28,17 @@
import puremagic
import requests
from bs4 import BeautifulSoup
from charset_normalizer import from_path

# Optional Transcription support
try:
import pydub
# Using warnings' catch_warnings to catch
# pydub's warning of ffmpeg or avconv missing
with catch_warnings(record=True) as w:
import pydub

if w:
raise ModuleNotFoundError
import speech_recognition as sr

IS_AUDIO_TRANSCRIPTION_CAPABLE = True
Expand Down Expand Up @@ -161,9 +170,7 @@ def convert(
elif "text/" not in content_type.lower():
return None

text_content = ""
with open(local_path, "rt", encoding="utf-8") as fh:
text_content = fh.read()
text_content = str(from_path(local_path).best())
return DocumentConverterResult(
title=None,
text_content=text_content,
Expand Down Expand Up @@ -492,7 +499,9 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:

result = None
with open(local_path, "rb") as docx_file:
result = mammoth.convert_to_html(docx_file)
style_map = kwargs.get("style_map", None)

result = mammoth.convert_to_html(docx_file, style_map=style_map)
html_content = result.value
result = self._convert(html_content)

Expand Down Expand Up @@ -582,6 +591,10 @@ def convert(self, local_path, **kwargs) -> Union[None, DocumentConverterResult]:
"\n" + self._convert(html_table).text_content.strip() + "\n"
)

# Charts
if shape.has_chart:
md_content += self._convert_chart_to_markdown(shape.chart)

# Text areas
elif shape.has_text_frame:
if shape == title:
Expand Down Expand Up @@ -616,6 +629,29 @@ def _is_table(self, shape):
return True
return False

def _convert_chart_to_markdown(self, chart):
md = "\n\n### Chart"
if chart.has_title:
md += f": {chart.chart_title.text_frame.text}"
md += "\n\n"
data = []
category_names = [c.label for c in chart.plots[0].categories]
series_names = [s.name for s in chart.series]
data.append(["Category"] + series_names)

for idx, category in enumerate(category_names):
row = [category]
for series in chart.series:
row.append(series.values[idx])
data.append(row)

markdown_table = []
for row in data:
markdown_table.append("| " + " | ".join(map(str, row)) + " |")
header = markdown_table[0]
separator = "|" + "|".join(["---"] * len(data[0])) + "|"
return md + "\n".join([header, separator] + markdown_table[1:])


class MediaConverter(DocumentConverter):
"""
Expand Down Expand Up @@ -837,6 +873,124 @@ def _get_mlm_description(self, local_path, extension, client, model, prompt=None
return response.choices[0].message.content


class ZipConverter(DocumentConverter):
"""Converts ZIP files to markdown by extracting and converting all contained files.
The converter extracts the ZIP contents to a temporary directory, processes each file
using appropriate converters based on file extensions, and then combines the results
into a single markdown document. The temporary directory is cleaned up after processing.
Example output format:
```markdown
Content from the zip file `example.zip`:
## File: docs/readme.txt
This is the content of readme.txt
Multiple lines are preserved
## File: images/example.jpg
ImageSize: 1920x1080
DateTimeOriginal: 2024-02-15 14:30:00
Description: A beautiful landscape photo
## File: data/report.xlsx
## Sheet1
| Column1 | Column2 | Column3 |
|---------|---------|---------|
| data1 | data2 | data3 |
| data4 | data5 | data6 |
```
Key features:
- Maintains original file structure in headings
- Processes nested files recursively
- Uses appropriate converters for each file type
- Preserves formatting of converted content
- Cleans up temporary files after processing
"""

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

# Get parent converters list if available
parent_converters = kwargs.get("_parent_converters", [])
if not parent_converters:
return DocumentConverterResult(
title=None,
text_content=f"[ERROR] No converters available to process zip contents from: {local_path}",
)

extracted_zip_folder_name = (
f"extracted_{os.path.basename(local_path).replace('.zip', '_zip')}"
)
new_folder = os.path.normpath(
os.path.join(os.path.dirname(local_path), extracted_zip_folder_name)
)
md_content = f"Content from the zip file `{os.path.basename(local_path)}`:\n\n"

# Safety check for path traversal
if not new_folder.startswith(os.path.dirname(local_path)):
return DocumentConverterResult(
title=None, text_content=f"[ERROR] Invalid zip file path: {local_path}"
)

try:
# Extract the zip file
with zipfile.ZipFile(local_path, "r") as zipObj:
zipObj.extractall(path=new_folder)

# Process each extracted file
for root, dirs, files in os.walk(new_folder):
for name in files:
file_path = os.path.join(root, name)
relative_path = os.path.relpath(file_path, new_folder)

# Get file extension
_, file_extension = os.path.splitext(name)

# Update kwargs for the file
file_kwargs = kwargs.copy()
file_kwargs["file_extension"] = file_extension
file_kwargs["_parent_converters"] = parent_converters

# Try converting the file using available converters
for converter in parent_converters:
# Skip the zip converter to avoid infinite recursion
if isinstance(converter, ZipConverter):
continue

result = converter.convert(file_path, **file_kwargs)
if result is not None:
md_content += f"\n## File: {relative_path}\n\n"
md_content += result.text_content + "\n\n"
break

# Clean up extracted files if specified
if kwargs.get("cleanup_extracted", True):
shutil.rmtree(new_folder)

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

except zipfile.BadZipFile:
return DocumentConverterResult(
title=None,
text_content=f"[ERROR] Invalid or corrupted zip file: {local_path}",
)
except Exception as e:
return DocumentConverterResult(
title=None,
text_content=f"[ERROR] Failed to process zip file {local_path}: {str(e)}",
)


class FileConversionException(BaseException):
pass

Expand All @@ -854,6 +1008,7 @@ def __init__(
requests_session: Optional[requests.Session] = None,
mlm_client: Optional[Any] = None,
mlm_model: Optional[Any] = None,
style_map: Optional[str] = None,
):
if requests_session is None:
self._requests_session = requests.Session()
Expand All @@ -862,6 +1017,7 @@ def __init__(

self._mlm_client = mlm_client
self._mlm_model = mlm_model
self._style_map = style_map

self._page_converters: List[DocumentConverter] = []

Expand All @@ -880,6 +1036,7 @@ def __init__(
self.register_page_converter(Mp3Converter())
self.register_page_converter(ImageConverter())
self.register_page_converter(PdfConverter())
self.register_page_converter(ZipConverter())

def convert(
self, source: Union[str, requests.Response], **kwargs: Any
Expand Down Expand Up @@ -1035,6 +1192,11 @@ def _convert(

if "mlm_model" not in _kwargs and self._mlm_model is not None:
_kwargs["mlm_model"] = self._mlm_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 we hit an error log it and keep trying
try:
Expand Down
Binary file modified tests/test_files/test.pptx
100755 → 100644
Binary file not shown.
Binary file added tests/test_files/test_files.zip
Binary file not shown.
4 changes: 4 additions & 0 deletions tests/test_files/test_mskanji.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
���O,�N��,�Z��
�������Y,30,����
�O�؉p�q,25,���
�����~,35,����
Binary file added tests/test_files/test_with_comment.docx
Binary file not shown.
Loading

0 comments on commit 83dc811

Please sign in to comment.