Compare commits
92 Commits
v0.0.1a5
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3da11ffe | ||
|
|
8a9d8f1593 | ||
|
|
17365654c9 | ||
|
|
59eb60f8cb | ||
|
|
459d462f29 | ||
|
|
c3f6cb356c | ||
|
|
0c4d3945a0 | ||
|
|
f8b60b5403 | ||
|
|
16ca285d30 | ||
|
|
b81a387616 | ||
|
|
ea1a3dfb60 | ||
|
|
b6e5da8874 | ||
|
|
fb1ad24833 | ||
|
|
1178c2e211 | ||
|
|
9278119bb3 | ||
|
|
da7bcea527 | ||
|
|
3bfb821c09 | ||
|
|
62b72284fe | ||
|
|
1dd3c83339 | ||
|
|
9dc982a3b1 | ||
|
|
effde4767b | ||
|
|
04bf831209 | ||
|
|
9fd680c366 | ||
|
|
38261fd31c | ||
|
|
131f0c7739 | ||
|
|
56f7579ce2 | ||
|
|
cb421cf9ea | ||
|
|
39e7252940 | ||
|
|
bbcf876b18 | ||
|
|
041be54471 | ||
|
|
ebe2684b3d | ||
|
|
8576f1d915 | ||
|
|
3fcd48cdfc | ||
|
|
9e067c42b6 | ||
|
|
9a951055f0 | ||
|
|
73b9d57312 | ||
|
|
3ca57986ef | ||
|
|
c1f9a323ee | ||
|
|
e928b43afb | ||
|
|
2ffe6ea591 | ||
|
|
efc55b260d | ||
|
|
52432bd228 | ||
|
|
c0a511ecff | ||
|
|
cd6aa41361 | ||
|
|
716f74dcb9 | ||
|
|
a93e0567e6 | ||
|
|
c5f70b904f | ||
|
|
53834fdd24 | ||
|
|
5c565b7d79 | ||
|
|
a78857bd43 | ||
|
|
09df7fe8df | ||
|
|
6a9f09b153 | ||
|
|
0b815fb916 | ||
|
|
12620f1545 | ||
|
|
5f75e16d20 | ||
|
|
75140a90e2 | ||
|
|
af1be36e0c | ||
|
|
2a2ccc86aa | ||
|
|
2e51ba22e7 | ||
|
|
8f8e58c9bb | ||
|
|
8e73a325c6 | ||
|
|
2405f201af | ||
|
|
99d8e562db | ||
|
|
515fa854bf | ||
|
|
0229ff6cb7 | ||
|
|
82d84e3edd | ||
|
|
36c4bc9ec3 | ||
|
|
80baa5db18 | ||
|
|
00a65e8f8b | ||
|
|
6bedf6d950 | ||
|
|
9380112892 | ||
|
|
784c293579 | ||
|
|
70e9f8c3c0 | ||
|
|
e921497f79 | ||
|
|
1d2f231146 | ||
|
|
c5cd659f63 | ||
|
|
f01c6c5277 | ||
|
|
43bd79adc9 | ||
|
|
9182923375 | ||
|
|
9a19fdd134 | ||
|
|
e82e0c1372 | ||
|
|
a394cc7c27 | ||
|
|
a87fbf01ee | ||
|
|
d0ed74fdf4 | ||
|
|
e4b419ba40 | ||
|
|
dbdf2c0c10 | ||
|
|
97eeed5f32 | ||
|
|
935da9976c | ||
|
|
5ce85c236c | ||
|
|
3a5ca22a8d | ||
|
|
4b62506451 | ||
|
|
c73afcffea |
@@ -1 +1,2 @@
|
|||||||
*
|
*
|
||||||
|
!packages/
|
||||||
|
|||||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1 +1,2 @@
|
|||||||
tests/test_files/** linguist-vendored
|
packages/markitdown/tests/test_files/** linguist-vendored
|
||||||
|
packages/markitdown-sample-plugin/tests/test_files/** linguist-vendored
|
||||||
|
|||||||
4
.github/workflows/pre-commit.yml
vendored
4
.github/workflows/pre-commit.yml
vendored
@@ -5,9 +5,9 @@ jobs:
|
|||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
|
|
||||||
|
|||||||
13
.github/workflows/tests.yml
vendored
13
.github/workflows/tests.yml
vendored
@@ -5,21 +5,14 @@ jobs:
|
|||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: |
|
python-version: |
|
||||||
3.10
|
3.10
|
||||||
3.11
|
3.11
|
||||||
3.12
|
3.12
|
||||||
- name: Set up pip cache
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }}
|
|
||||||
restore-keys: ${{ runner.os }}-pip-
|
|
||||||
- name: Install Hatch
|
- name: Install Hatch
|
||||||
run: pipx install hatch
|
run: pipx install hatch
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: hatch test
|
run: cd packages/markitdown; hatch test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -164,3 +164,4 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
src/.DS_Store
|
src/.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.cursorrules
|
||||||
|
|||||||
30
Dockerfile
30
Dockerfile
@@ -1,22 +1,32 @@
|
|||||||
FROM python:3.13-slim-bullseye
|
FROM python:3.13-slim-bullseye
|
||||||
|
|
||||||
USER root
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV EXIFTOOL_PATH=/usr/bin/exiftool
|
||||||
ARG INSTALL_GIT=false
|
ENV FFMPEG_PATH=/usr/bin/ffmpeg
|
||||||
RUN if [ "$INSTALL_GIT" = "true" ]; then \
|
|
||||||
apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Runtime dependency
|
# Runtime dependency
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
exiftool
|
||||||
|
|
||||||
RUN pip install markitdown
|
ARG INSTALL_GIT=false
|
||||||
|
RUN if [ "$INSTALL_GIT" = "true" ]; then \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
RUN rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
RUN pip --no-cache-dir install \
|
||||||
|
/app/packages/markitdown[all] \
|
||||||
|
/app/packages/markitdown-sample-plugin
|
||||||
|
|
||||||
# Default USERID and GROUPID
|
# Default USERID and GROUPID
|
||||||
ARG USERID=10000
|
ARG USERID=nobody
|
||||||
ARG GROUPID=10000
|
ARG GROUPID=nogroup
|
||||||
|
|
||||||
USER $USERID:$GROUPID
|
USER $USERID:$GROUPID
|
||||||
|
|
||||||
|
|||||||
170
README.md
170
README.md
@@ -4,9 +4,19 @@
|
|||||||

|

|
||||||
[](https://github.com/microsoft/autogen)
|
[](https://github.com/microsoft/autogen)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> MarkItDown now offers an MCP (Model Context Protocol) server for integration with LLM applications like Claude Desktop. See [markitdown-mcp](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp) for more information.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Breaking changes between 0.0.1 to 0.1.0:
|
||||||
|
> * Dependencies are now organized into optional feature-groups (further details below). Use `pip install 'markitdown[all]'` to have backward-compatible behavior.
|
||||||
|
> * convert\_stream() now requires a binary file-like object (e.g., a file opened in binary mode, or an io.BytesIO object). This is a breaking change from the previous version, where it previously also accepted text file-like objects, like io.StringIO.
|
||||||
|
> * The DocumentConverter class interface has changed to read from file-like streams rather than file paths. *No temporary files are created anymore*. If you are the maintainer of a plugin, or custom DocumentConverter, you likely need to update your code. Otherwise, if only using the MarkItDown class or CLI (as in these examples), you should not need to change anything.
|
||||||
|
|
||||||
|
MarkItDown is a lightweight Python utility for converting various files to Markdown for use with LLMs and related text analysis pipelines. To this end, it is most comparable to [textract](https://github.com/deanmalmgren/textract), but with a focus on preserving important document structure and content as Markdown (including: headings, lists, tables, links, etc.) While the output is often reasonably presentable and human-friendly, it is meant to be consumed by text analysis tools -- and may not be the best option for high-fidelity document conversions for human consumption.
|
||||||
|
|
||||||
|
MarkItDown currently supports the conversion from:
|
||||||
|
|
||||||
MarkItDown is a utility for converting various files to Markdown (e.g., for indexing, text analysis, etc).
|
|
||||||
It supports:
|
|
||||||
- PDF
|
- PDF
|
||||||
- PowerPoint
|
- PowerPoint
|
||||||
- Word
|
- Word
|
||||||
@@ -16,8 +26,53 @@ It supports:
|
|||||||
- HTML
|
- HTML
|
||||||
- Text-based formats (CSV, JSON, XML)
|
- Text-based formats (CSV, JSON, XML)
|
||||||
- ZIP files (iterates over contents)
|
- ZIP files (iterates over contents)
|
||||||
|
- Youtube URLs
|
||||||
|
- EPubs
|
||||||
|
- ... and more!
|
||||||
|
|
||||||
To install MarkItDown, use pip: `pip install markitdown`. Alternatively, you can install it from the source: `pip install -e .`
|
## Why Markdown?
|
||||||
|
|
||||||
|
Markdown is extremely close to plain text, with minimal markup or formatting, but still
|
||||||
|
provides a way to represent important document structure. Mainstream LLMs, such as
|
||||||
|
OpenAI's GPT-4o, natively "_speak_" Markdown, and often incorporate Markdown into their
|
||||||
|
responses unprompted. This suggests that they have been trained on vast amounts of
|
||||||
|
Markdown-formatted text, and understand it well. As a side benefit, Markdown conventions
|
||||||
|
are also highly token-efficient.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
MarkItDown requires Python 3.10 or higher. It is recommended to use a virtual environment to avoid dependency conflicts.
|
||||||
|
|
||||||
|
With the standard Python installation, you can create and activate a virtual environment using the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
If using `uv`, you can create a virtual environment with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv --python=3.12 .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
# NOTE: Be sure to use 'uv pip install' rather than just 'pip install' to install packages in this virtual environment
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using Anaconda, you can create a virtual environment with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda create -n markitdown python=3.12
|
||||||
|
conda activate markitdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install MarkItDown, use pip: `pip install 'markitdown[all]'`. Alternatively, you can install it from the source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:microsoft/markitdown.git
|
||||||
|
cd markitdown
|
||||||
|
pip install -e 'packages/markitdown[all]'
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -33,18 +88,58 @@ Or use `-o` to specify the output file:
|
|||||||
markitdown path-to-file.pdf -o document.md
|
markitdown path-to-file.pdf -o document.md
|
||||||
```
|
```
|
||||||
|
|
||||||
To use Document Intelligence conversion:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
markitdown path-to-file.pdf -o document.md -d -e "<document_intelligence_endpoint>"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also pipe content:
|
You can also pipe content:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat path-to-file.pdf | markitdown
|
cat path-to-file.pdf | markitdown
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Optional Dependencies
|
||||||
|
MarkItDown has optional dependencies for activating various file formats. Earlier in this document, we installed all optional dependencies with the `[all]` option. However, you can also install them individually for more control. For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install 'markitdown[pdf, docx, pptx]'
|
||||||
|
```
|
||||||
|
|
||||||
|
will install only the dependencies for PDF, DOCX, and PPTX files.
|
||||||
|
|
||||||
|
At the moment, the following optional dependencies are available:
|
||||||
|
|
||||||
|
* `[all]` Installs all optional dependencies
|
||||||
|
* `[pptx]` Installs dependencies for PowerPoint files
|
||||||
|
* `[docx]` Installs dependencies for Word files
|
||||||
|
* `[xlsx]` Installs dependencies for Excel files
|
||||||
|
* `[xls]` Installs dependencies for older Excel files
|
||||||
|
* `[pdf]` Installs dependencies for PDF files
|
||||||
|
* `[outlook]` Installs dependencies for Outlook messages
|
||||||
|
* `[az-doc-intel]` Installs dependencies for Azure Document Intelligence
|
||||||
|
* `[audio-transcription]` Installs dependencies for audio transcription of wav and mp3 files
|
||||||
|
* `[youtube-transcription]` Installs dependencies for fetching YouTube video transcription
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
MarkItDown also supports 3rd-party plugins. Plugins are disabled by default. To list installed plugins:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown --list-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
To enable plugins use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown --use-plugins path-to-file.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
To find available plugins, search GitHub for the hashtag `#markitdown-plugin`. To develop a plugin, see `packages/markitdown-sample-plugin`.
|
||||||
|
|
||||||
|
### Azure Document Intelligence
|
||||||
|
|
||||||
|
To use Microsoft Document Intelligence for conversion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown path-to-file.pdf -o document.md -d -e "<document_intelligence_endpoint>"
|
||||||
|
```
|
||||||
|
|
||||||
More information about how to set up an Azure Document Intelligence Resource can be found [here](https://learn.microsoft.com/en-us/azure/ai-services/document-intelligence/how-to-guides/create-document-intelligence-resource?view=doc-intel-4.0.0)
|
More information about how to set up an Azure Document Intelligence Resource can be found [here](https://learn.microsoft.com/en-us/azure/ai-services/document-intelligence/how-to-guides/create-document-intelligence-resource?view=doc-intel-4.0.0)
|
||||||
|
|
||||||
### Python API
|
### Python API
|
||||||
@@ -54,7 +149,7 @@ Basic usage in Python:
|
|||||||
```python
|
```python
|
||||||
from markitdown import MarkItDown
|
from markitdown import MarkItDown
|
||||||
|
|
||||||
md = MarkItDown()
|
md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
|
||||||
result = md.convert("test.xlsx")
|
result = md.convert("test.xlsx")
|
||||||
print(result.text_content)
|
print(result.text_content)
|
||||||
```
|
```
|
||||||
@@ -69,14 +164,14 @@ result = md.convert("test.pdf")
|
|||||||
print(result.text_content)
|
print(result.text_content)
|
||||||
```
|
```
|
||||||
|
|
||||||
To use Large Language Models for image descriptions, provide `llm_client` and `llm_model`:
|
To use Large Language Models for image descriptions (currently only for pptx and image files), provide `llm_client` and `llm_model`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from markitdown import MarkItDown
|
from markitdown import MarkItDown
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
client = OpenAI()
|
client = OpenAI()
|
||||||
md = MarkItDown(llm_client=client, llm_model="gpt-4o")
|
md = MarkItDown(llm_client=client, llm_model="gpt-4o", llm_prompt="optional custom prompt")
|
||||||
result = md.convert("example.jpg")
|
result = md.convert("example.jpg")
|
||||||
print(result.text_content)
|
print(result.text_content)
|
||||||
```
|
```
|
||||||
@@ -87,42 +182,6 @@ print(result.text_content)
|
|||||||
docker build -t markitdown:latest .
|
docker build -t markitdown:latest .
|
||||||
docker run --rm -i markitdown:latest < ~/your-file.pdf > output.md
|
docker run --rm -i markitdown:latest < ~/your-file.pdf > output.md
|
||||||
```
|
```
|
||||||
<details>
|
|
||||||
|
|
||||||
<summary>Batch Processing Multiple Files</summary>
|
|
||||||
|
|
||||||
This example shows how to convert multiple files to markdown format in a single run. The script processes all supported files in a directory and creates corresponding markdown files.
|
|
||||||
|
|
||||||
|
|
||||||
```python convert.py
|
|
||||||
from markitdown import MarkItDown
|
|
||||||
from openai import OpenAI
|
|
||||||
import os
|
|
||||||
client = OpenAI(api_key="your-api-key-here")
|
|
||||||
md = MarkItDown(llm_client=client, llm_model="gpt-4o-2024-11-20")
|
|
||||||
supported_extensions = ('.pptx', '.docx', '.pdf', '.jpg', '.jpeg', '.png')
|
|
||||||
files_to_convert = [f for f in os.listdir('.') if f.lower().endswith(supported_extensions)]
|
|
||||||
for file in files_to_convert:
|
|
||||||
print(f"\nConverting {file}...")
|
|
||||||
try:
|
|
||||||
md_file = os.path.splitext(file)[0] + '.md'
|
|
||||||
result = md.convert(file)
|
|
||||||
with open(md_file, 'w') as f:
|
|
||||||
f.write(result.text_content)
|
|
||||||
|
|
||||||
print(f"Successfully converted {file} to {md_file}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error converting {file}: {str(e)}")
|
|
||||||
|
|
||||||
print("\nAll conversions completed!")
|
|
||||||
```
|
|
||||||
2. Place the script in the same directory as your files
|
|
||||||
3. Install required packages: like openai
|
|
||||||
4. Run script ```bash python convert.py ```
|
|
||||||
|
|
||||||
Note that original files will remain unchanged and new markdown files are created with the same base name.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -142,11 +201,10 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio
|
|||||||
|
|
||||||
You can help by looking at issues or helping review PRs. Any issue or PR is welcome, but we have also marked some as 'open for contribution' and 'open for reviewing' to help facilitate community contributions. These are of course just suggestions and you are welcome to contribute in any way you like.
|
You can help by looking at issues or helping review PRs. Any issue or PR is welcome, but we have also marked some as 'open for contribution' and 'open for reviewing' to help facilitate community contributions. These are of course just suggestions and you are welcome to contribute in any way you like.
|
||||||
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
| | All | Especially Needs Help from Community |
|
| | All | Especially Needs Help from Community |
|
||||||
|-----------------------|------------------------------------------|------------------------------------------------------------------------------------------|
|
| ---------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Issues** | [All Issues](https://github.com/microsoft/markitdown/issues) | [Issues open for contribution](https://github.com/microsoft/markitdown/issues?q=is%3Aissue+is%3Aopen+label%3A%22open+for+contribution%22) |
|
| **Issues** | [All Issues](https://github.com/microsoft/markitdown/issues) | [Issues open for contribution](https://github.com/microsoft/markitdown/issues?q=is%3Aissue+is%3Aopen+label%3A%22open+for+contribution%22) |
|
||||||
| **PRs** | [All PRs](https://github.com/microsoft/markitdown/pulls) | [PRs open for reviewing](https://github.com/microsoft/markitdown/pulls?q=is%3Apr+is%3Aopen+label%3A%22open+for+reviewing%22) |
|
| **PRs** | [All PRs](https://github.com/microsoft/markitdown/pulls) | [PRs open for reviewing](https://github.com/microsoft/markitdown/pulls?q=is%3Apr+is%3Aopen+label%3A%22open+for+reviewing%22) |
|
||||||
|
|
||||||
@@ -154,7 +212,14 @@ You can help by looking at issues or helping review PRs. Any issue or PR is welc
|
|||||||
|
|
||||||
### Running Tests and Checks
|
### Running Tests and Checks
|
||||||
|
|
||||||
|
- Navigate to the MarkItDown package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/markitdown
|
||||||
|
```
|
||||||
|
|
||||||
- Install `hatch` in your environment and run tests:
|
- Install `hatch` in your environment and run tests:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install hatch # Other ways of installing hatch: https://hatch.pypa.io/dev/install/
|
pip install hatch # Other ways of installing hatch: https://hatch.pypa.io/dev/install/
|
||||||
hatch shell
|
hatch shell
|
||||||
@@ -162,6 +227,7 @@ You can help by looking at issues or helping review PRs. Any issue or PR is welc
|
|||||||
```
|
```
|
||||||
|
|
||||||
(Alternative) Use the Devcontainer which has all the dependencies installed:
|
(Alternative) Use the Devcontainer which has all the dependencies installed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Reopen the project in Devcontainer and run:
|
# Reopen the project in Devcontainer and run:
|
||||||
hatch test
|
hatch test
|
||||||
@@ -169,6 +235,10 @@ You can help by looking at issues or helping review PRs. Any issue or PR is welc
|
|||||||
|
|
||||||
- Run pre-commit checks before submitting a PR: `pre-commit run --all-files`
|
- Run pre-commit checks before submitting a PR: `pre-commit run --all-files`
|
||||||
|
|
||||||
|
### Contributing 3rd-party Plugins
|
||||||
|
|
||||||
|
You can also contribute by creating and sharing 3rd party plugins. See `packages/markitdown-sample-plugin` for more details.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
||||||
|
|||||||
28
packages/markitdown-mcp/Dockerfile
Normal file
28
packages/markitdown-mcp/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.13-slim-bullseye
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV EXIFTOOL_PATH=/usr/bin/exiftool
|
||||||
|
ENV FFMPEG_PATH=/usr/bin/ffmpeg
|
||||||
|
ENV MARKITDOWN_ENABLE_PLUGINS=True
|
||||||
|
|
||||||
|
# Runtime dependency
|
||||||
|
# NOTE: Add any additional MarkItDown plugins here
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
exiftool
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
RUN rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
RUN pip --no-cache-dir install /app
|
||||||
|
|
||||||
|
WORKDIR /workdir
|
||||||
|
|
||||||
|
# Default USERID and GROUPID
|
||||||
|
ARG USERID=nobody
|
||||||
|
ARG GROUPID=nogroup
|
||||||
|
|
||||||
|
USER $USERID:$GROUPID
|
||||||
|
|
||||||
|
ENTRYPOINT [ "markitdown-mcp" ]
|
||||||
138
packages/markitdown-mcp/README.md
Normal file
138
packages/markitdown-mcp/README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# MarkItDown-MCP
|
||||||
|
|
||||||
|
[](https://pypi.org/project/markitdown-mcp/)
|
||||||
|

|
||||||
|
[](https://github.com/microsoft/autogen)
|
||||||
|
|
||||||
|
The `markitdown-mcp` package provides a lightweight STDIO, Streamable HTTP, and SSE MCP server for calling MarkItDown.
|
||||||
|
|
||||||
|
It exposes one tool: `convert_to_markdown(uri)`, where uri can be any `http:`, `https:`, `file:`, or `data:` URI.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To install the package, use pip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install markitdown-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To run the MCP server, using STDIO (default) use the following command:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
To run the MCP server, using Streamable HTTP and SSE use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown-mcp --http --host 127.0.0.1 --port 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running in Docker
|
||||||
|
|
||||||
|
To run `markitdown-mcp` in Docker, build the Docker image using the provided Dockerfile:
|
||||||
|
```bash
|
||||||
|
docker build -t markitdown-mcp:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
And run it using:
|
||||||
|
```bash
|
||||||
|
docker run -it --rm markitdown-mcp:latest
|
||||||
|
```
|
||||||
|
This will be sufficient for remote URIs. To access local files, you need to mount the local directory into the container. For example, if you want to access files in `/home/user/data`, you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v /home/user/data:/workdir markitdown-mcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Once mounted, all files under data will be accessible under `/workdir` in the container. For example, if you have a file `example.txt` in `/home/user/data`, it will be accessible in the container at `/workdir/example.txt`.
|
||||||
|
|
||||||
|
## Accessing from Claude Desktop
|
||||||
|
|
||||||
|
It is recommended to use the Docker image when running the MCP server for Claude Desktop.
|
||||||
|
|
||||||
|
Follow [these instructions](https://modelcontextprotocol.io/quickstart/user#for-claude-desktop-users) to access Claude's `claude_desktop_config.json` file.
|
||||||
|
|
||||||
|
Edit it to include the following JSON entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"markitdown": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"markitdown-mcp:latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to mount a directory, adjust it accordingly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"markitdown": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"-v",
|
||||||
|
"/home/user/data:/workdir",
|
||||||
|
"markitdown-mcp:latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
To debug the MCP server you can use the `mcpinspector` tool.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then connect to the inspector through the specified host and port (e.g., `http://localhost:5173/`).
|
||||||
|
|
||||||
|
If using STDIO:
|
||||||
|
* select `STDIO` as the transport type,
|
||||||
|
* input `markitdown-mcp` as the command, and
|
||||||
|
* click `Connect`
|
||||||
|
|
||||||
|
If using Streamable HTTP:
|
||||||
|
* select `Streamable HTTP` as the transport type,
|
||||||
|
* input `http://127.0.0.1:3001/mcp` as the URL, and
|
||||||
|
* click `Connect`
|
||||||
|
|
||||||
|
If using SSE:
|
||||||
|
* select `SSE` as the transport type,
|
||||||
|
* input `http://127.0.0.1:3001/sse` as the URL, and
|
||||||
|
* click `Connect`
|
||||||
|
|
||||||
|
Finally:
|
||||||
|
* click the `Tools` tab,
|
||||||
|
* click `List Tools`,
|
||||||
|
* click `convert_to_markdown`, and
|
||||||
|
* run the tool on any valid URI.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
The server does not support authentication, and runs with the privileges of the user running it. For this reason, when running in SSE or Streamable HTTP mode, it is recommended to run the server bound to `localhost` (default).
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
||||||
|
trademarks or logos is subject to and must follow
|
||||||
|
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
|
||||||
|
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
|
||||||
|
Any use of third-party trademarks or logos are subject to those third-party's policies.
|
||||||
69
packages/markitdown-mcp/pyproject.toml
Normal file
69
packages/markitdown-mcp/pyproject.toml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "markitdown-mcp"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = 'An MCP server for the "markitdown" library.'
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
keywords = []
|
||||||
|
authors = [
|
||||||
|
{ name = "Adam Fourney", email = "adamfo@microsoft.com" },
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"mcp~=1.8.0",
|
||||||
|
"markitdown[all]>=0.1.1,<0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Documentation = "https://github.com/microsoft/markitdown#readme"
|
||||||
|
Issues = "https://github.com/microsoft/markitdown/issues"
|
||||||
|
Source = "https://github.com/microsoft/markitdown"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "src/markitdown_mcp/__about__.py"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
markitdown-mcp = "markitdown_mcp.__main__:main"
|
||||||
|
|
||||||
|
[tool.hatch.envs.types]
|
||||||
|
extra-dependencies = [
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
]
|
||||||
|
[tool.hatch.envs.types.scripts]
|
||||||
|
check = "mypy --install-types --non-interactive {args:src/markitdown_mcp tests}"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source_pkgs = ["markitdown-mcp", "tests"]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
omit = [
|
||||||
|
"src/markitdown_mcp/__about__.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.paths]
|
||||||
|
markitdown-mcp = ["src/markitdown_mcp", "*/markitdown-mcp/src/markitdown_mcp"]
|
||||||
|
tests = ["tests", "*/markitdown-mcp/tests"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"no cov",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
only-include = ["src/markitdown_mcp"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
__version__ = "0.0.1a3"
|
__version__ = "0.0.1a4"
|
||||||
9
packages/markitdown-mcp/src/markitdown_mcp/__init__.py
Normal file
9
packages/markitdown-mcp/src/markitdown_mcp/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from .__about__ import __version__
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
127
packages/markitdown-mcp/src/markitdown_mcp/__main__.py
Normal file
127
packages/markitdown-mcp/src/markitdown_mcp/__main__.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import contextlib
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from mcp.server.sse import SseServerTransport
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
from starlette.types import Receive, Scope, Send
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||||
|
from markitdown import MarkItDown
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Initialize FastMCP server for MarkItDown (SSE)
|
||||||
|
mcp = FastMCP("markitdown")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def convert_to_markdown(uri: str) -> str:
|
||||||
|
"""Convert a resource described by an http:, https:, file: or data: URI to markdown"""
|
||||||
|
return MarkItDown(enable_plugins=check_plugins_enabled()).convert_uri(uri).markdown
|
||||||
|
|
||||||
|
|
||||||
|
def check_plugins_enabled() -> bool:
|
||||||
|
return os.getenv("MARKITDOWN_ENABLE_PLUGINS", "false").strip().lower() in (
|
||||||
|
"true",
|
||||||
|
"1",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
|
||||||
|
sse = SseServerTransport("/messages/")
|
||||||
|
session_manager = StreamableHTTPSessionManager(
|
||||||
|
app=mcp_server,
|
||||||
|
event_store=None,
|
||||||
|
json_response=True,
|
||||||
|
stateless=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_sse(request: Request) -> None:
|
||||||
|
async with sse.connect_sse(
|
||||||
|
request.scope,
|
||||||
|
request.receive,
|
||||||
|
request._send,
|
||||||
|
) as (read_stream, write_stream):
|
||||||
|
await mcp_server.run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
mcp_server.create_initialization_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_streamable_http(
|
||||||
|
scope: Scope, receive: Receive, send: Send
|
||||||
|
) -> None:
|
||||||
|
await session_manager.handle_request(scope, receive, send)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||||
|
"""Context manager for session manager."""
|
||||||
|
async with session_manager.run():
|
||||||
|
print("Application started with StreamableHTTP session manager!")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
print("Application shutting down...")
|
||||||
|
|
||||||
|
return Starlette(
|
||||||
|
debug=debug,
|
||||||
|
routes=[
|
||||||
|
Route("/sse", endpoint=handle_sse),
|
||||||
|
Mount("/mcp", app=handle_streamable_http),
|
||||||
|
Mount("/messages/", app=sse.handle_post_message),
|
||||||
|
],
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Main entry point
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
mcp_server = mcp._mcp_server
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Run a MarkItDown MCP server")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--http",
|
||||||
|
action="store_true",
|
||||||
|
help="Run the server with Streamable HTTP and SSE transport rather than STDIO (default: False)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sse",
|
||||||
|
action="store_true",
|
||||||
|
help="(Deprecated) An alias for --http (default: False)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default=None, help="Host to bind to (default: 127.0.0.1)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=None, help="Port to listen on (default: 3001)"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
use_http = args.http or args.sse
|
||||||
|
|
||||||
|
if not use_http and (args.host or args.port):
|
||||||
|
parser.error(
|
||||||
|
"Host and port arguments are only valid when using streamable HTTP or SSE transport (see: --http)."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if use_http:
|
||||||
|
starlette_app = create_starlette_app(mcp_server, debug=True)
|
||||||
|
uvicorn.run(
|
||||||
|
starlette_app,
|
||||||
|
host=args.host if args.host else "127.0.0.1",
|
||||||
|
port=args.port if args.port else 3001,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
111
packages/markitdown-sample-plugin/README.md
Normal file
111
packages/markitdown-sample-plugin/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# MarkItDown Sample Plugin
|
||||||
|
|
||||||
|
[](https://pypi.org/project/markitdown-sample-plugin/)
|
||||||
|

|
||||||
|
[](https://github.com/microsoft/autogen)
|
||||||
|
|
||||||
|
|
||||||
|
This project shows how to create a sample plugin for MarkItDown. The most important parts are as follows:
|
||||||
|
|
||||||
|
Next, implement your custom DocumentConverter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from markitdown import MarkItDown, DocumentConverter, DocumentConverterResult, StreamInfo
|
||||||
|
|
||||||
|
class RtfConverter(DocumentConverter):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, priority: float = DocumentConverter.PRIORITY_SPECIFIC_FILE_FORMAT
|
||||||
|
):
|
||||||
|
super().__init__(priority=priority)
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
# Implement logic to check if the file stream is an RTF file
|
||||||
|
# ...
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
|
||||||
|
# Implement logic to convert the file stream to Markdown
|
||||||
|
# ...
|
||||||
|
raise NotImplementedError()
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, make sure your package implements and exports the following:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# The version of the plugin interface that this plugin uses.
|
||||||
|
# The only supported version is 1 for now.
|
||||||
|
__plugin_interface_version__ = 1
|
||||||
|
|
||||||
|
# The main entrypoint for the plugin. This is called each time MarkItDown instances are created.
|
||||||
|
def register_converters(markitdown: MarkItDown, **kwargs):
|
||||||
|
"""
|
||||||
|
Called during construction of MarkItDown instances to register converters provided by plugins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Simply create and attach an RtfConverter instance
|
||||||
|
markitdown.register_converter(RtfConverter())
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Finally, create an entrypoint in the `pyproject.toml` file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.entry-points."markitdown.plugin"]
|
||||||
|
sample_plugin = "markitdown_sample_plugin"
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, the value of `sample_plugin` can be any key, but should ideally be the name of the plugin. The value is the fully qualified name of the package implementing the plugin.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To use the plugin with MarkItDown, it must be installed. To install the plugin from the current directory use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the plugin package is installed, verify that it is available to MarkItDown by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown --list-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
To use the plugin for a conversion use the `--use-plugins` flag. For example, to convert an RTF file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown --use-plugins path-to-file.rtf
|
||||||
|
```
|
||||||
|
|
||||||
|
In Python, plugins can be enabled as follows:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from markitdown import MarkItDown
|
||||||
|
|
||||||
|
md = MarkItDown(enable_plugins=True)
|
||||||
|
result = md.convert("path-to-file.rtf")
|
||||||
|
print(result.text_content)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
||||||
|
trademarks or logos is subject to and must follow
|
||||||
|
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
|
||||||
|
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
|
||||||
|
Any use of third-party trademarks or logos are subject to those third-party's policies.
|
||||||
70
packages/markitdown-sample-plugin/pyproject.toml
Normal file
70
packages/markitdown-sample-plugin/pyproject.toml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "markitdown-sample-plugin"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = 'A sample plugin for the "markitdown" library.'
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
keywords = []
|
||||||
|
authors = [
|
||||||
|
{ name = "Adam Fourney", email = "adamfo@microsoft.com" },
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"markitdown>=0.1.0a1",
|
||||||
|
"striprtf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Documentation = "https://github.com/microsoft/markitdown#readme"
|
||||||
|
Issues = "https://github.com/microsoft/markitdown/issues"
|
||||||
|
Source = "https://github.com/microsoft/markitdown"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "src/markitdown_sample_plugin/__about__.py"
|
||||||
|
|
||||||
|
# IMPORTANT: MarkItDown will look for this entry point to find the plugin.
|
||||||
|
[project.entry-points."markitdown.plugin"]
|
||||||
|
sample_plugin = "markitdown_sample_plugin"
|
||||||
|
|
||||||
|
[tool.hatch.envs.types]
|
||||||
|
extra-dependencies = [
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
]
|
||||||
|
[tool.hatch.envs.types.scripts]
|
||||||
|
check = "mypy --install-types --non-interactive {args:src/markitdown_sample_plugin tests}"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source_pkgs = ["markitdown-sample-plugin", "tests"]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
omit = [
|
||||||
|
"src/markitdown_sample_plugin/__about__.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.paths]
|
||||||
|
markitdown-sample-plugin = ["src/markitdown_sample_plugin", "*/markitdown-sample-plugin/src/markitdown_sample_plugin"]
|
||||||
|
tests = ["tests", "*/markitdown-sample-plugin/tests"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"no cov",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
only-include = ["src/markitdown_sample_plugin"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
__version__ = "0.1.0a1"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from ._plugin import __plugin_interface_version__, register_converters, RtfConverter
|
||||||
|
from .__about__ import __version__
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"__plugin_interface_version__",
|
||||||
|
"register_converters",
|
||||||
|
"RtfConverter",
|
||||||
|
]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import locale
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from striprtf.striprtf import rtf_to_text
|
||||||
|
|
||||||
|
from markitdown import (
|
||||||
|
MarkItDown,
|
||||||
|
DocumentConverter,
|
||||||
|
DocumentConverterResult,
|
||||||
|
StreamInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__plugin_interface_version__ = (
|
||||||
|
1 # The version of the plugin interface that this plugin uses
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/rtf",
|
||||||
|
"application/rtf",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".rtf"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_converters(markitdown: MarkItDown, **kwargs):
|
||||||
|
"""
|
||||||
|
Called during construction of MarkItDown instances to register converters provided by plugins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Simply create and attach an RtfConverter instance
|
||||||
|
markitdown.register_converter(RtfConverter())
|
||||||
|
|
||||||
|
|
||||||
|
class RtfConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts an RTF file to in the simplest possible way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Read the file stream into an str using hte provided charset encoding, or using the system default
|
||||||
|
encoding = stream_info.charset or locale.getpreferredencoding()
|
||||||
|
stream_data = file_stream.read().decode(encoding)
|
||||||
|
|
||||||
|
# Return the result
|
||||||
|
return DocumentConverterResult(
|
||||||
|
title=None,
|
||||||
|
markdown=rtf_to_text(stream_data),
|
||||||
|
)
|
||||||
3
packages/markitdown-sample-plugin/tests/__init__.py
Normal file
3
packages/markitdown-sample-plugin/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
251
packages/markitdown-sample-plugin/tests/test_files/test.rtf
vendored
Executable file
251
packages/markitdown-sample-plugin/tests/test_files/test.rtf
vendored
Executable file
@@ -0,0 +1,251 @@
|
|||||||
|
{\rtf1\adeflang1025\ansi\ansicpg1252\uc1\adeff31507\deff0\stshfdbch31506\stshfloch31506\stshfhich31506\stshfbi31507\deflang1033\deflangfe1033\themelang1033\themelangfe0\themelangcs0{\fonttbl{\f0\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f34\fbidi \froman\fcharset0\fprq2{\*\panose 02040503050406030204}Cambria Math;}
|
||||||
|
{\f42\fbidi \fswiss\fcharset0\fprq2 Aptos Display;}{\f43\fbidi \fswiss\fcharset0\fprq2 Aptos;}{\flomajor\f31500\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}
|
||||||
|
{\fdbmajor\f31501\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fhimajor\f31502\fbidi \fswiss\fcharset0\fprq2 Aptos Display;}{\fbimajor\f31503\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}
|
||||||
|
{\flominor\f31504\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fdbminor\f31505\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\fhiminor\f31506\fbidi \fswiss\fcharset0\fprq2 Aptos;}
|
||||||
|
{\fbiminor\f31507\fbidi \froman\fcharset0\fprq2{\*\panose 02020603050405020304}Times New Roman;}{\f51\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\f52\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}
|
||||||
|
{\f54\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\f55\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\f56\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\f57\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}
|
||||||
|
{\f58\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\f59\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\f391\fbidi \froman\fcharset238\fprq2 Cambria Math CE;}{\f392\fbidi \froman\fcharset204\fprq2 Cambria Math Cyr;}
|
||||||
|
{\f394\fbidi \froman\fcharset161\fprq2 Cambria Math Greek;}{\f395\fbidi \froman\fcharset162\fprq2 Cambria Math Tur;}{\f398\fbidi \froman\fcharset186\fprq2 Cambria Math Baltic;}{\f399\fbidi \froman\fcharset163\fprq2 Cambria Math (Vietnamese);}
|
||||||
|
{\f471\fbidi \fswiss\fcharset238\fprq2 Aptos Display CE;}{\f472\fbidi \fswiss\fcharset204\fprq2 Aptos Display Cyr;}{\f474\fbidi \fswiss\fcharset161\fprq2 Aptos Display Greek;}{\f475\fbidi \fswiss\fcharset162\fprq2 Aptos Display Tur;}
|
||||||
|
{\f478\fbidi \fswiss\fcharset186\fprq2 Aptos Display Baltic;}{\f479\fbidi \fswiss\fcharset163\fprq2 Aptos Display (Vietnamese);}{\f481\fbidi \fswiss\fcharset238\fprq2 Aptos CE;}{\f482\fbidi \fswiss\fcharset204\fprq2 Aptos Cyr;}
|
||||||
|
{\f484\fbidi \fswiss\fcharset161\fprq2 Aptos Greek;}{\f485\fbidi \fswiss\fcharset162\fprq2 Aptos Tur;}{\f488\fbidi \fswiss\fcharset186\fprq2 Aptos Baltic;}{\f489\fbidi \fswiss\fcharset163\fprq2 Aptos (Vietnamese);}
|
||||||
|
{\flomajor\f31508\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\flomajor\f31509\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flomajor\f31511\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}
|
||||||
|
{\flomajor\f31512\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\flomajor\f31513\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flomajor\f31514\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}
|
||||||
|
{\flomajor\f31515\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\flomajor\f31516\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbmajor\f31518\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}
|
||||||
|
{\fdbmajor\f31519\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fdbmajor\f31521\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fdbmajor\f31522\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
|
||||||
|
{\fdbmajor\f31523\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fdbmajor\f31524\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fdbmajor\f31525\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
|
||||||
|
{\fdbmajor\f31526\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fhimajor\f31528\fbidi \fswiss\fcharset238\fprq2 Aptos Display CE;}{\fhimajor\f31529\fbidi \fswiss\fcharset204\fprq2 Aptos Display Cyr;}
|
||||||
|
{\fhimajor\f31531\fbidi \fswiss\fcharset161\fprq2 Aptos Display Greek;}{\fhimajor\f31532\fbidi \fswiss\fcharset162\fprq2 Aptos Display Tur;}{\fhimajor\f31535\fbidi \fswiss\fcharset186\fprq2 Aptos Display Baltic;}
|
||||||
|
{\fhimajor\f31536\fbidi \fswiss\fcharset163\fprq2 Aptos Display (Vietnamese);}{\fbimajor\f31538\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fbimajor\f31539\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}
|
||||||
|
{\fbimajor\f31541\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fbimajor\f31542\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fbimajor\f31543\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}
|
||||||
|
{\fbimajor\f31544\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fbimajor\f31545\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fbimajor\f31546\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}
|
||||||
|
{\flominor\f31548\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\flominor\f31549\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\flominor\f31551\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}
|
||||||
|
{\flominor\f31552\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\flominor\f31553\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\flominor\f31554\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}
|
||||||
|
{\flominor\f31555\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\flominor\f31556\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fdbminor\f31558\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}
|
||||||
|
{\fdbminor\f31559\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}{\fdbminor\f31561\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fdbminor\f31562\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}
|
||||||
|
{\fdbminor\f31563\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}{\fdbminor\f31564\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fdbminor\f31565\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}
|
||||||
|
{\fdbminor\f31566\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}{\fhiminor\f31568\fbidi \fswiss\fcharset238\fprq2 Aptos CE;}{\fhiminor\f31569\fbidi \fswiss\fcharset204\fprq2 Aptos Cyr;}
|
||||||
|
{\fhiminor\f31571\fbidi \fswiss\fcharset161\fprq2 Aptos Greek;}{\fhiminor\f31572\fbidi \fswiss\fcharset162\fprq2 Aptos Tur;}{\fhiminor\f31575\fbidi \fswiss\fcharset186\fprq2 Aptos Baltic;}
|
||||||
|
{\fhiminor\f31576\fbidi \fswiss\fcharset163\fprq2 Aptos (Vietnamese);}{\fbiminor\f31578\fbidi \froman\fcharset238\fprq2 Times New Roman CE;}{\fbiminor\f31579\fbidi \froman\fcharset204\fprq2 Times New Roman Cyr;}
|
||||||
|
{\fbiminor\f31581\fbidi \froman\fcharset161\fprq2 Times New Roman Greek;}{\fbiminor\f31582\fbidi \froman\fcharset162\fprq2 Times New Roman Tur;}{\fbiminor\f31583\fbidi \froman\fcharset177\fprq2 Times New Roman (Hebrew);}
|
||||||
|
{\fbiminor\f31584\fbidi \froman\fcharset178\fprq2 Times New Roman (Arabic);}{\fbiminor\f31585\fbidi \froman\fcharset186\fprq2 Times New Roman Baltic;}{\fbiminor\f31586\fbidi \froman\fcharset163\fprq2 Times New Roman (Vietnamese);}}
|
||||||
|
{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;
|
||||||
|
\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;\red0\green0\blue0;\red0\green0\blue0;\caccentone\ctint255\cshade191\red15\green71\blue97;
|
||||||
|
\ctextone\ctint166\cshade255\red89\green89\blue89;\ctextone\ctint216\cshade255\red39\green39\blue39;\ctextone\ctint191\cshade255\red64\green64\blue64;}{\*\defchp \f31506\fs24\kerning2 }{\*\defpap \ql \li0\ri0\sa160\sl278\slmult1
|
||||||
|
\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 }\noqfpromote {\stylesheet{\ql \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025
|
||||||
|
\ltrch\fcs0 \f31506\fs24\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033 \snext0 \sqformat \spriority0 Normal;}{\s1\ql \li0\ri0\sb360\sa80\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel0\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs40\alang1025 \ltrch\fcs0
|
||||||
|
\fs40\cf19\lang1033\langfe1033\kerning2\loch\f31502\hich\af31502\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink15 \sqformat \spriority9 \styrsid15678446 heading 1;}{\s2\ql \li0\ri0\sb160\sa80\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel1\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs32\alang1025 \ltrch\fcs0
|
||||||
|
\fs32\cf19\lang1033\langfe1033\kerning2\loch\f31502\hich\af31502\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink16 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 2;}{\s3\ql \li0\ri0\sb160\sa80\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel2\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs28\alang1025 \ltrch\fcs0
|
||||||
|
\fs28\cf19\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink17 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 3;}{\s4\ql \li0\ri0\sb80\sa40\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel3\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \ai\af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\i\fs24\cf19\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink18 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 4;}{\s5\ql \li0\ri0\sb80\sa40\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel4\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\fs24\cf19\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink19 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 5;}{\s6\ql \li0\ri0\sb40\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel5\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \ai\af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\i\fs24\cf20\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink20 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 6;}{\s7\ql \li0\ri0\sb40\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel6\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\fs24\cf20\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink21 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 7;}{\s8\ql \li0\ri0\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel7\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \ai\af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\i\fs24\cf21\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink22 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 8;}{\s9\ql \li0\ri0\sl278\slmult1
|
||||||
|
\keep\keepn\widctlpar\wrapdefault\aspalpha\aspnum\faauto\outlinelevel8\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\fs24\cf21\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink23 \ssemihidden \sunhideused \sqformat \spriority9 \styrsid15678446 heading 9;}{\*\cs10 \additive
|
||||||
|
\ssemihidden \sunhideused \spriority1 Default Paragraph Font;}{\*
|
||||||
|
\ts11\tsrowd\trftsWidthB3\trpaddl108\trpaddr108\trpaddfl3\trpaddft3\trpaddfb3\trpaddfr3\trcbpat1\trcfpat1\tblind0\tblindtype3\tsvertalt\tsbrdrt\tsbrdrl\tsbrdrb\tsbrdrr\tsbrdrdgl\tsbrdrdgr\tsbrdrh\tsbrdrv \ql \li0\ri0\sa160\sl278\slmult1
|
||||||
|
\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0 \f31506\fs24\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033 \snext11 \ssemihidden \sunhideused Normal Table;}{\*\cs15
|
||||||
|
\additive \rtlch\fcs1 \af31503\afs40 \ltrch\fcs0 \fs40\cf19\loch\f31502\hich\af31502\dbch\af31501 \sbasedon10 \slink1 \spriority9 \styrsid15678446 Heading 1 Char;}{\*\cs16 \additive \rtlch\fcs1 \af31503\afs32 \ltrch\fcs0
|
||||||
|
\fs32\cf19\loch\f31502\hich\af31502\dbch\af31501 \sbasedon10 \slink2 \ssemihidden \spriority9 \styrsid15678446 Heading 2 Char;}{\*\cs17 \additive \rtlch\fcs1 \af31503\afs28 \ltrch\fcs0 \fs28\cf19\dbch\af31501
|
||||||
|
\sbasedon10 \slink3 \ssemihidden \spriority9 \styrsid15678446 Heading 3 Char;}{\*\cs18 \additive \rtlch\fcs1 \ai\af31503 \ltrch\fcs0 \i\cf19\dbch\af31501 \sbasedon10 \slink4 \ssemihidden \spriority9 \styrsid15678446 Heading 4 Char;}{\*\cs19 \additive
|
||||||
|
\rtlch\fcs1 \af31503 \ltrch\fcs0 \cf19\dbch\af31501 \sbasedon10 \slink5 \ssemihidden \spriority9 \styrsid15678446 Heading 5 Char;}{\*\cs20 \additive \rtlch\fcs1 \ai\af31503 \ltrch\fcs0 \i\cf20\dbch\af31501
|
||||||
|
\sbasedon10 \slink6 \ssemihidden \spriority9 \styrsid15678446 Heading 6 Char;}{\*\cs21 \additive \rtlch\fcs1 \af31503 \ltrch\fcs0 \cf20\dbch\af31501 \sbasedon10 \slink7 \ssemihidden \spriority9 \styrsid15678446 Heading 7 Char;}{\*\cs22 \additive
|
||||||
|
\rtlch\fcs1 \ai\af31503 \ltrch\fcs0 \i\cf21\dbch\af31501 \sbasedon10 \slink8 \ssemihidden \spriority9 \styrsid15678446 Heading 8 Char;}{\*\cs23 \additive \rtlch\fcs1 \af31503 \ltrch\fcs0 \cf21\dbch\af31501
|
||||||
|
\sbasedon10 \slink9 \ssemihidden \spriority9 \styrsid15678446 Heading 9 Char;}{\s24\ql \li0\ri0\sa80\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0\contextualspace \rtlch\fcs1 \af31503\afs56\alang1025 \ltrch\fcs0
|
||||||
|
\fs56\expnd-2\expndtw-10\lang1033\langfe1033\kerning28\loch\f31502\hich\af31502\dbch\af31501\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink25 \sqformat \spriority10 \styrsid15678446 Title;}{\*\cs25 \additive \rtlch\fcs1 \af31503\afs56
|
||||||
|
\ltrch\fcs0 \fs56\expnd-2\expndtw-10\kerning28\loch\f31502\hich\af31502\dbch\af31501 \sbasedon10 \slink24 \spriority10 \styrsid15678446 Title Char;}{\s26\ql \li0\ri0\sa160\sl278\slmult1
|
||||||
|
\widctlpar\wrapdefault\aspalpha\aspnum\faauto\ilvl1\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31503\afs28\alang1025 \ltrch\fcs0 \fs28\expnd3\expndtw15\cf20\lang1033\langfe1033\kerning2\loch\f31506\hich\af31506\dbch\af31501\cgrid\langnp1033\langfenp1033
|
||||||
|
\sbasedon0 \snext0 \slink27 \sqformat \spriority11 \styrsid15678446 Subtitle;}{\*\cs27 \additive \rtlch\fcs1 \af31503\afs28 \ltrch\fcs0 \fs28\expnd3\expndtw15\cf20\dbch\af31501 \sbasedon10 \slink26 \spriority11 \styrsid15678446 Subtitle Char;}{
|
||||||
|
\s28\qc \li0\ri0\sb160\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \ai\af31507\afs24\alang1025 \ltrch\fcs0 \i\f31506\fs24\cf22\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033
|
||||||
|
\sbasedon0 \snext0 \slink29 \sqformat \spriority29 \styrsid15678446 Quote;}{\*\cs29 \additive \rtlch\fcs1 \ai\af0 \ltrch\fcs0 \i\cf22 \sbasedon10 \slink28 \spriority29 \styrsid15678446 Quote Char;}{\s30\ql \li720\ri0\sa160\sl278\slmult1
|
||||||
|
\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin720\itap0\contextualspace \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0 \f31506\fs24\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033
|
||||||
|
\sbasedon0 \snext30 \sqformat \spriority34 \styrsid15678446 List Paragraph;}{\*\cs31 \additive \rtlch\fcs1 \ai\af0 \ltrch\fcs0 \i\cf19 \sbasedon10 \sqformat \spriority21 \styrsid15678446 Intense Emphasis;}{\s32\qc \li864\ri864\sb360\sa360\sl278\slmult1
|
||||||
|
\widctlpar\brdrt\brdrs\brdrw10\brsp200\brdrcf19 \brdrb\brdrs\brdrw10\brsp200\brdrcf19 \wrapdefault\aspalpha\aspnum\faauto\adjustright\rin864\lin864\itap0 \rtlch\fcs1 \ai\af31507\afs24\alang1025 \ltrch\fcs0
|
||||||
|
\i\f31506\fs24\cf19\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033 \sbasedon0 \snext0 \slink33 \sqformat \spriority30 \styrsid15678446 Intense Quote;}{\*\cs33 \additive \rtlch\fcs1 \ai\af0 \ltrch\fcs0 \i\cf19
|
||||||
|
\sbasedon10 \slink32 \spriority30 \styrsid15678446 Intense Quote Char;}{\*\cs34 \additive \rtlch\fcs1 \ab\af0 \ltrch\fcs0 \b\scaps\expnd1\expndtw5\cf19 \sbasedon10 \sqformat \spriority32 \styrsid15678446 Intense Reference;}}{\*\rsidtbl \rsid3543682
|
||||||
|
\rsid6316520\rsid7364952\rsid8278432\rsid9589131\rsid10298217\rsid15678446\rsid15953651}{\mmathPr\mmathFont34\mbrkBin0\mbrkBinSub0\msmallFrac0\mdispDef1\mlMargin0\mrMargin0\mdefJc1\mwrapIndent1440\mintLim0\mnaryLim1}{\info{\author Adam Fourney}
|
||||||
|
{\operator Adam Fourney}{\creatim\yr2025\mo2\dy9\hr22\min56}{\revtim\yr2025\mo2\dy9\hr22\min58}{\version1}{\edmins2}{\nofpages1}{\nofwords17}{\nofchars98}{\nofcharsws114}{\vern115}}{\*\xmlnstbl {\xmlns1 http://schemas.microsoft.com/office/word/2003/wordm
|
||||||
|
l}}\paperw12240\paperh15840\margl1440\margr1440\margt1440\margb1440\gutter0\ltrsect
|
||||||
|
\widowctrl\ftnbj\aenddoc\trackmoves0\trackformatting1\donotembedsysfont1\relyonvml0\donotembedlingdata0\grfdocevents0\validatexml1\showplaceholdtext0\ignoremixedcontent0\saveinvalidxml0\showxmlerrors1\noxlattoyen
|
||||||
|
\expshrtn\noultrlspc\dntblnsbdb\nospaceforul\formshade\horzdoc\dgmargin\dghspace180\dgvspace180\dghorigin1440\dgvorigin1440\dghshow1\dgvshow1
|
||||||
|
\jexpand\viewkind1\viewscale100\pgbrdrhead\pgbrdrfoot\splytwnine\ftnlytwnine\htmautsp\nolnhtadjtbl\useltbaln\alntblind\lytcalctblwd\lyttblrtgr\lnbrkrule\nobrkwrptbl\snaptogridincell\allowfieldendsel\wrppunct
|
||||||
|
\asianbrkrule\rsidroot15678446\newtblstyruls\nogrowautofit\usenormstyforlist\noindnmbrts\felnbrelev\nocxsptable\indrlsweleven\noafcnsttbl\afelev\utinl\hwelev\spltpgpar\notcvasp\notbrkcnstfrctbl\notvatxbx\krnprsnet\cachedcolbal \nouicompat \fet0
|
||||||
|
{\*\wgrffmtfilter 2450}\nofeaturethrottle1\ilfomacatclnup0\ltrpar \sectd \ltrsect\linex0\endnhere\sectlinegrid360\sectdefaultcl\sftnbj {\*\pnseclvl1\pnucrm\pnstart1\pnindent720\pnhang {\pntxta .}}{\*\pnseclvl2\pnucltr\pnstart1\pnindent720\pnhang
|
||||||
|
{\pntxta .}}{\*\pnseclvl3\pndec\pnstart1\pnindent720\pnhang {\pntxta .}}{\*\pnseclvl4\pnlcltr\pnstart1\pnindent720\pnhang {\pntxta )}}{\*\pnseclvl5\pndec\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl6\pnlcltr\pnstart1\pnindent720\pnhang
|
||||||
|
{\pntxtb (}{\pntxta )}}{\*\pnseclvl7\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl8\pnlcltr\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}{\*\pnseclvl9\pnlcrm\pnstart1\pnindent720\pnhang {\pntxtb (}{\pntxta )}}
|
||||||
|
\pard\plain \ltrpar\s24\ql \li0\ri0\sa80\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0\pararsid15678446\contextualspace \rtlch\fcs1 \af31503\afs56\alang1025 \ltrch\fcs0
|
||||||
|
\fs56\expnd-2\expndtw-10\lang1033\langfe1033\kerning28\loch\af31502\hich\af31502\dbch\af31501\cgrid\langnp1033\langfenp1033 {\rtlch\fcs1 \af31503 \ltrch\fcs0 \insrsid15678446 \hich\af31502\dbch\af31501\loch\f31502 This is a
|
||||||
|
\hich\af31502\dbch\af31501\loch\f31502 S\hich\af31502\dbch\af31501\loch\f31502 ample RT\hich\af31502\dbch\af31501\loch\f31502 F \hich\af31502\dbch\af31501\loch\f31502 File}{\rtlch\fcs1 \af31503 \ltrch\fcs0 \insrsid8278432
|
||||||
|
\par }\pard\plain \ltrpar\ql \li0\ri0\sa160\sl278\slmult1\widctlpar\wrapdefault\aspalpha\aspnum\faauto\adjustright\rin0\lin0\itap0 \rtlch\fcs1 \af31507\afs24\alang1025 \ltrch\fcs0 \f31506\fs24\lang1033\langfe1033\kerning2\cgrid\langnp1033\langfenp1033 {
|
||||||
|
\rtlch\fcs1 \af31507 \ltrch\fcs0 \insrsid15678446
|
||||||
|
\par It is included to test if the MarkItDown sample plugin can correctly convert RTF files.
|
||||||
|
\par }{\*\themedata 504b030414000600080000002100e9de0fbfff0000001c020000130000005b436f6e74656e745f54797065735d2e786d6cac91cb4ec3301045f748fc83e52d4a
|
||||||
|
9cb2400825e982c78ec7a27cc0c8992416c9d8b2a755fbf74cd25442a820166c2cd933f79e3be372bd1f07b5c3989ca74aaff2422b24eb1b475da5df374fd9ad
|
||||||
|
5689811a183c61a50f98f4babebc2837878049899a52a57be670674cb23d8e90721f90a4d2fa3802cb35762680fd800ecd7551dc18eb899138e3c943d7e503b6
|
||||||
|
b01d583deee5f99824e290b4ba3f364eac4a430883b3c092d4eca8f946c916422ecab927f52ea42b89a1cd59c254f919b0e85e6535d135a8de20f20b8c12c3b0
|
||||||
|
0c895fcf6720192de6bf3b9e89ecdbd6596cbcdd8eb28e7c365ecc4ec1ff1460f53fe813d3cc7f5b7f020000ffff0300504b030414000600080000002100a5d6
|
||||||
|
a7e7c0000000360100000b0000005f72656c732f2e72656c73848fcf6ac3300c87ef85bd83d17d51d2c31825762fa590432fa37d00e1287f68221bdb1bebdb4f
|
||||||
|
c7060abb0884a4eff7a93dfeae8bf9e194e720169aaa06c3e2433fcb68e1763dbf7f82c985a4a725085b787086a37bdbb55fbc50d1a33ccd311ba548b6309512
|
||||||
|
0f88d94fbc52ae4264d1c910d24a45db3462247fa791715fd71f989e19e0364cd3f51652d73760ae8fa8c9ffb3c330cc9e4fc17faf2ce545046e37944c69e462
|
||||||
|
a1a82fe353bd90a865aad41ed0b5b8f9d6fd010000ffff0300504b0304140006000800000021006b799616830000008a0000001c0000007468656d652f746865
|
||||||
|
6d652f7468656d654d616e616765722e786d6c0ccc4d0ac3201040e17da17790d93763bb284562b2cbaebbf600439c1a41c7a0d29fdbd7e5e38337cedf14d59b
|
||||||
|
4b0d592c9c070d8a65cd2e88b7f07c2ca71ba8da481cc52c6ce1c715e6e97818c9b48d13df49c873517d23d59085adb5dd20d6b52bd521ef2cdd5eb9246a3d8b
|
||||||
|
4757e8d3f729e245eb2b260a0238fd010000ffff0300504b030414000600080000002100d3d1e707f007000012220000160000007468656d652f7468656d652f
|
||||||
|
7468656d65312e786d6cec5a4b8fdbc811be07c87f20789745ea414903cb0b3d3d6bcfd8034b76b0c796d812dbd36413ecd6cc080b0381f794cb020b6c825c02
|
||||||
|
e496431064812c90452ef931066c249b1f91ea2645754b2dcf030662043373215b5f557f5d555d556cf2e1175731752e70c6094bbaaeffc0731d9ccc59489265
|
||||||
|
d77d391d57daaec3054a42445982bbee1a73f78b47bffcc5437424221c6307e4137e84ba6e24447a54adf2390c23fe80a53881df162c8b9180db6c590d337409
|
||||||
|
7a635aad795e508d11495c274131a87dbe58903976a652a5fb68a37c44e136115c0ecc693691aab121a1b0e1b92f117ccd0734732e10edba304fc82ea7f84ab8
|
||||||
|
0e455cc00f5dd7537f6ef5d1c32a3a2a84a83820abc98dd55f21570884e7353567b69c95937aa35abbe197fa15808a7ddca82dff4b7d0a80e6735869ce45d7e9
|
||||||
|
3703af5d2bb01a28bfb4e8eeb4fcba89d7f4d7f738fb9da05f6b18fa1528d7dfd8c37be3ce68d834f00a94e39b7bf89e57eb77ea065e81727cb0876f8c7aadda
|
||||||
|
c8c02b50444972be8f0e5aed7650a04bc882d1632bbc13045e6b58c0b728888632bae4140b968843b116a3d72c1b03400229122471c43ac50b348728eea58271
|
||||||
|
6748784ad1da755294300ec35ecdf721f41a5eadfc571647471869d2921730e17b43928fc3e7194945d77d025a5d0df2fea79fdebdfdf1dddbbfbffbe69b776f
|
||||||
|
ffea9c906524725586dc314a96badccf7ffaee3f7ff8b5f3efbffdf1e7ef7f6bc7731dffe12fbff9f08f7f7e4c3d6cb5ad29deffee870f3ffef0fef7dffeebcf
|
||||||
|
df5bb4f73234d3e1531263ee3cc397ce0b16c30295294cfe7896dd4e621a21a24bf49225470992b358f48f4464a09fad1145165c1f9b767c9541aab1011faf5e
|
||||||
|
1b842751b612c4a2f169141bc053c6689f65562b3c957369669eae92a57df26ca5e35e2074619b7b8012c3cba3550a3996d8540e226cd03ca328116889132c1c
|
||||||
|
f91b3bc7d8b2baaf0831ec7a4ae619e36c219caf88d347c46a92299919d1b4153a2631f8656d2308fe366c73facae9336a5bf5105f9848d81b885ac84f3135cc
|
||||||
|
f818ad048a6d2aa728a6bac14f90886c2427eb6caee3465c80a7979832671462ce6d32cf3358afe6f4a708b29bd5eda7741d9bc84c90739bce13c4988e1cb2f3
|
||||||
|
4184e2d4869d9024d2b15ff2730851e49c3161839f327387c87bf0034a0ebafb15c186bbafcf062f21cbe994b601227f5965165f3ec6cc88dfc99a2e10b6a59a
|
||||||
|
5e161b29b697116b74f4574b23b44f30a6e81285183b2fbfb430e8b3d4b0f996f49308b2ca31b605d61364c6aabc4f30875e493637fb79f2847023642778c90e
|
||||||
|
f0395def249e354a62941dd2fc0cbcaedb7c34cb60335a283ca7f3731df88c400f08f16235ca730e3ab4e03ea8f52c42460193f7dc1eafebccf0df4df618eccb
|
||||||
|
d7068d1bec4b90c1b79681c4aecb7cd43653448d09b6013345c439b1a55b1031dcbf1591c55589adac720b73d36edd00dd91d1f4c424b9a603fadf743e9640fc
|
||||||
|
343d8f5db191b06ed9ed1c4a28c73b3dce21dc6e67336059483effc6668856c919865ab29fb5eefb9afbbec6fdbfef6b0eede7fb6ee650cf71dfcdb8d065dc77
|
||||||
|
33c501cba7e966b60d0cf436f290213fec51473ff1c1939f05a17422d6149f7075f8c3e199261cc3a09453a79eb83c094c23b894650e263070cb0c29192763e2
|
||||||
|
5744449308a57042e4bb52c99217aa97dc4919878323356cd52df174159fb2303ff054274c5e5e593912db71af09474ff9381c56891c1db48a41c94f9daa025f
|
||||||
|
c576a90e5b3704a4ec6d4868939924ea1612adcde03524e4d9d9a761d1b1b0684bf51b57ed9902a8955e81876e071ed5bb6eb32109c149399f43831e4a3fe5ae
|
||||||
|
de785739f3537afa90318d0880c3c57c2570345f7aba23b91e5c9e5c5d1e6a37f0b4414239250f2b9384b28c6af078048fc24574cad19bd0b8adaf3b5b971af4
|
||||||
|
a429d47c10df5b1aadf6c758dcd5d720b79b1b68a2670a9a38975d37a8372164e628edba0b383886cb3885d8e1f2b90bd125bc7d998b2cdff077c92c69c6c510
|
||||||
|
f12837b84a3ab97b622270e65012775db9fcd20d3451394471f36b90103e5b721d482b9f1b3970bae964bc58e0b9d0ddae8d484be7b790e1f35c61fd5589df1d
|
||||||
|
2c25d90adc3d89c24b674657d90b0421d66cf9d28021e1f0fec0cfad191278215626b26dfced14a622f9eb6fa4540ce5e388a6112a2a8a9ecc73b8aa27251d75
|
||||||
|
57da40bb2bd60c06d54c5214c2d9521658dda846352d4b57cee160d5bd5e485a4e4b9adb9a6964155935ed59cc98615306766c79b722afb1da9818729a5ee1f3
|
||||||
|
d4bd9b723b9b5cb7d3279455020c5edaef6ea55fa3b69dcca02619efa76199b38b51b3766c16780db59b14092deb071bb53b762b6b84753a18bc53e507b9dda8
|
||||||
|
85a1c5a6af5496566fcef597db6cf61a92c710badc15cd5f77d304ee6454f2f42c53be9db1705d5c529e279adce7b22795489abcc00b8784579b7eb2746fbe3d
|
||||||
|
f257ae7ed10c28b41493b5ab14b4367ba6608197a2f986bd8d7029a16686d6bb1456c78ab67e575c6d28cb561df0ca843c5f3598b6b0145ced5b118ec83304ad
|
||||||
|
ed44357679ee05da57a2c82f70e5ac32d275bff69abdc6a0d61c54bc76735469d41b5ea5ddecd52bbd66b3ee8f9abe37ecd7de003d11c57e33fff4610c6f82e8
|
||||||
|
baf800428def7d04116f5e763d98b3b8cad4470e55e57df511845f3bfc110438126805b571a7dee907954ebd37ae3486fd76a53308fa956130680dc7c341b3dd
|
||||||
|
19bf719d0b056ef4ea8346306a57027f30a834024fd26f772aad46add66bb47aed51a3f7a6703fac3ccfc1852dc07c8ad7a3ff020000ffff0300504b03041400
|
||||||
|
06000800000021000dd1909fb60000001b010000270000007468656d652f7468656d652f5f72656c732f7468656d654d616e616765722e786d6c2e72656c7384
|
||||||
|
8f4d0ac2301484f78277086f6fd3ba109126dd88d0add40384e4350d363f2451eced0dae2c082e8761be9969bb979dc9136332de3168aa1a083ae995719ac16d
|
||||||
|
b8ec8e4052164e89d93b64b060828e6f37ed1567914b284d262452282e3198720e274a939cd08a54f980ae38a38f56e422a3a641c8bbd048f7757da0f19b017c
|
||||||
|
c524bd62107bd5001996509affb3fd381a89672f1f165dfe514173d9850528a2c6cce0239baa4c04ca5bbabac4df000000ffff0300504b01022d001400060008
|
||||||
|
0000002100e9de0fbfff0000001c0200001300000000000000000000000000000000005b436f6e74656e745f54797065735d2e786d6c504b01022d0014000600
|
||||||
|
080000002100a5d6a7e7c0000000360100000b00000000000000000000000000300100005f72656c732f2e72656c73504b01022d00140006000800000021006b
|
||||||
|
799616830000008a0000001c00000000000000000000000000190200007468656d652f7468656d652f7468656d654d616e616765722e786d6c504b01022d0014
|
||||||
|
000600080000002100d3d1e707f0070000122200001600000000000000000000000000d60200007468656d652f7468656d652f7468656d65312e786d6c504b01
|
||||||
|
022d00140006000800000021000dd1909fb60000001b0100002700000000000000000000000000fa0a00007468656d652f7468656d652f5f72656c732f7468656d654d616e616765722e786d6c2e72656c73504b050600000000050005005d010000f50b00000000}
|
||||||
|
{\*\colorschememapping 3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d3822207374616e64616c6f6e653d22796573223f3e0d0a3c613a636c724d
|
||||||
|
617020786d6c6e733a613d22687474703a2f2f736368656d61732e6f70656e786d6c666f726d6174732e6f72672f64726177696e676d6c2f323030362f6d6169
|
||||||
|
6e22206267313d226c743122207478313d22646b3122206267323d226c743222207478323d22646b322220616363656e74313d22616363656e74312220616363
|
||||||
|
656e74323d22616363656e74322220616363656e74333d22616363656e74332220616363656e74343d22616363656e74342220616363656e74353d22616363656e74352220616363656e74363d22616363656e74362220686c696e6b3d22686c696e6b2220666f6c486c696e6b3d22666f6c486c696e6b222f3e}
|
||||||
|
{\*\latentstyles\lsdstimax376\lsdlockeddef0\lsdsemihiddendef0\lsdunhideuseddef0\lsdqformatdef0\lsdprioritydef99{\lsdlockedexcept \lsdqformat1 \lsdpriority0 \lsdlocked0 Normal;\lsdqformat1 \lsdpriority9 \lsdlocked0 heading 1;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 2;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 3;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 4;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 5;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 6;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 7;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 8;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority9 \lsdlocked0 heading 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 1;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 5;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index 9;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 1;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 2;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 4;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 5;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 6;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 7;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 8;\lsdsemihidden1 \lsdunhideused1 \lsdpriority39 \lsdlocked0 toc 9;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal Indent;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 header;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footer;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 index heading;\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority35 \lsdlocked0 caption;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of figures;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 envelope return;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 footnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation reference;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 line number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 page number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote reference;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 endnote text;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 table of authorities;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 macro;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 toa heading;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Bullet 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Number 5;\lsdqformat1 \lsdpriority10 \lsdlocked0 Title;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Closing;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Signature;\lsdsemihidden1 \lsdunhideused1 \lsdpriority1 \lsdlocked0 Default Paragraph Font;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 4;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 List Continue 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Message Header;\lsdqformat1 \lsdpriority11 \lsdlocked0 Subtitle;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Salutation;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Date;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text First Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Note Heading;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Body Text Indent 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Block Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 FollowedHyperlink;\lsdqformat1 \lsdpriority22 \lsdlocked0 Strong;
|
||||||
|
\lsdqformat1 \lsdpriority20 \lsdlocked0 Emphasis;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Document Map;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Plain Text;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 E-mail Signature;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Top of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Bottom of Form;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal (Web);\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Acronym;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Address;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Cite;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Code;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Definition;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Keyboard;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Preformatted;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Sample;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Typewriter;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 HTML Variable;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Normal Table;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 annotation subject;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 No List;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Outline List 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 1;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Simple 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Classic 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Colorful 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 3;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Columns 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 6;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Grid 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 4;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 5;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 6;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 7;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table List 8;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table 3D effects 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Contemporary;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Elegant;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Professional;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Subtle 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Subtle 2;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 1;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 2;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Web 3;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Balloon Text;\lsdpriority39 \lsdlocked0 Table Grid;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Table Theme;\lsdsemihidden1 \lsdlocked0 Placeholder Text;
|
||||||
|
\lsdqformat1 \lsdpriority1 \lsdlocked0 No Spacing;\lsdpriority60 \lsdlocked0 Light Shading;\lsdpriority61 \lsdlocked0 Light List;\lsdpriority62 \lsdlocked0 Light Grid;\lsdpriority63 \lsdlocked0 Medium Shading 1;\lsdpriority64 \lsdlocked0 Medium Shading 2;
|
||||||
|
\lsdpriority65 \lsdlocked0 Medium List 1;\lsdpriority66 \lsdlocked0 Medium List 2;\lsdpriority67 \lsdlocked0 Medium Grid 1;\lsdpriority68 \lsdlocked0 Medium Grid 2;\lsdpriority69 \lsdlocked0 Medium Grid 3;\lsdpriority70 \lsdlocked0 Dark List;
|
||||||
|
\lsdpriority71 \lsdlocked0 Colorful Shading;\lsdpriority72 \lsdlocked0 Colorful List;\lsdpriority73 \lsdlocked0 Colorful Grid;\lsdpriority60 \lsdlocked0 Light Shading Accent 1;\lsdpriority61 \lsdlocked0 Light List Accent 1;
|
||||||
|
\lsdpriority62 \lsdlocked0 Light Grid Accent 1;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 1;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 1;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 1;\lsdsemihidden1 \lsdlocked0 Revision;
|
||||||
|
\lsdqformat1 \lsdpriority34 \lsdlocked0 List Paragraph;\lsdqformat1 \lsdpriority29 \lsdlocked0 Quote;\lsdqformat1 \lsdpriority30 \lsdlocked0 Intense Quote;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 1;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 1;
|
||||||
|
\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 1;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 1;\lsdpriority70 \lsdlocked0 Dark List Accent 1;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 1;\lsdpriority72 \lsdlocked0 Colorful List Accent 1;
|
||||||
|
\lsdpriority73 \lsdlocked0 Colorful Grid Accent 1;\lsdpriority60 \lsdlocked0 Light Shading Accent 2;\lsdpriority61 \lsdlocked0 Light List Accent 2;\lsdpriority62 \lsdlocked0 Light Grid Accent 2;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 2;
|
||||||
|
\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 2;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 2;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 2;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 2;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 2;
|
||||||
|
\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 2;\lsdpriority70 \lsdlocked0 Dark List Accent 2;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 2;\lsdpriority72 \lsdlocked0 Colorful List Accent 2;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 2;
|
||||||
|
\lsdpriority60 \lsdlocked0 Light Shading Accent 3;\lsdpriority61 \lsdlocked0 Light List Accent 3;\lsdpriority62 \lsdlocked0 Light Grid Accent 3;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 3;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 3;
|
||||||
|
\lsdpriority65 \lsdlocked0 Medium List 1 Accent 3;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 3;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 3;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 3;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 3;
|
||||||
|
\lsdpriority70 \lsdlocked0 Dark List Accent 3;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 3;\lsdpriority72 \lsdlocked0 Colorful List Accent 3;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 3;\lsdpriority60 \lsdlocked0 Light Shading Accent 4;
|
||||||
|
\lsdpriority61 \lsdlocked0 Light List Accent 4;\lsdpriority62 \lsdlocked0 Light Grid Accent 4;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 4;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 4;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 4;
|
||||||
|
\lsdpriority66 \lsdlocked0 Medium List 2 Accent 4;\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 4;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 4;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 4;\lsdpriority70 \lsdlocked0 Dark List Accent 4;
|
||||||
|
\lsdpriority71 \lsdlocked0 Colorful Shading Accent 4;\lsdpriority72 \lsdlocked0 Colorful List Accent 4;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 4;\lsdpriority60 \lsdlocked0 Light Shading Accent 5;\lsdpriority61 \lsdlocked0 Light List Accent 5;
|
||||||
|
\lsdpriority62 \lsdlocked0 Light Grid Accent 5;\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 5;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 5;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 5;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 5;
|
||||||
|
\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 5;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 5;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 5;\lsdpriority70 \lsdlocked0 Dark List Accent 5;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 5;
|
||||||
|
\lsdpriority72 \lsdlocked0 Colorful List Accent 5;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 5;\lsdpriority60 \lsdlocked0 Light Shading Accent 6;\lsdpriority61 \lsdlocked0 Light List Accent 6;\lsdpriority62 \lsdlocked0 Light Grid Accent 6;
|
||||||
|
\lsdpriority63 \lsdlocked0 Medium Shading 1 Accent 6;\lsdpriority64 \lsdlocked0 Medium Shading 2 Accent 6;\lsdpriority65 \lsdlocked0 Medium List 1 Accent 6;\lsdpriority66 \lsdlocked0 Medium List 2 Accent 6;
|
||||||
|
\lsdpriority67 \lsdlocked0 Medium Grid 1 Accent 6;\lsdpriority68 \lsdlocked0 Medium Grid 2 Accent 6;\lsdpriority69 \lsdlocked0 Medium Grid 3 Accent 6;\lsdpriority70 \lsdlocked0 Dark List Accent 6;\lsdpriority71 \lsdlocked0 Colorful Shading Accent 6;
|
||||||
|
\lsdpriority72 \lsdlocked0 Colorful List Accent 6;\lsdpriority73 \lsdlocked0 Colorful Grid Accent 6;\lsdqformat1 \lsdpriority19 \lsdlocked0 Subtle Emphasis;\lsdqformat1 \lsdpriority21 \lsdlocked0 Intense Emphasis;
|
||||||
|
\lsdqformat1 \lsdpriority31 \lsdlocked0 Subtle Reference;\lsdqformat1 \lsdpriority32 \lsdlocked0 Intense Reference;\lsdqformat1 \lsdpriority33 \lsdlocked0 Book Title;\lsdsemihidden1 \lsdunhideused1 \lsdpriority37 \lsdlocked0 Bibliography;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdqformat1 \lsdpriority39 \lsdlocked0 TOC Heading;\lsdpriority41 \lsdlocked0 Plain Table 1;\lsdpriority42 \lsdlocked0 Plain Table 2;\lsdpriority43 \lsdlocked0 Plain Table 3;\lsdpriority44 \lsdlocked0 Plain Table 4;
|
||||||
|
\lsdpriority45 \lsdlocked0 Plain Table 5;\lsdpriority40 \lsdlocked0 Grid Table Light;\lsdpriority46 \lsdlocked0 Grid Table 1 Light;\lsdpriority47 \lsdlocked0 Grid Table 2;\lsdpriority48 \lsdlocked0 Grid Table 3;\lsdpriority49 \lsdlocked0 Grid Table 4;
|
||||||
|
\lsdpriority50 \lsdlocked0 Grid Table 5 Dark;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 1;
|
||||||
|
\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 1;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 1;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 1;
|
||||||
|
\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 1;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 2;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 2;
|
||||||
|
\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 2;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 2;
|
||||||
|
\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 3;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 3;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 3;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 3;
|
||||||
|
\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 3;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 4;
|
||||||
|
\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 4;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 4;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 4;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 4;
|
||||||
|
\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 4;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 5;
|
||||||
|
\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 5;\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 5;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 5;
|
||||||
|
\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 5;\lsdpriority46 \lsdlocked0 Grid Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 Grid Table 2 Accent 6;\lsdpriority48 \lsdlocked0 Grid Table 3 Accent 6;
|
||||||
|
\lsdpriority49 \lsdlocked0 Grid Table 4 Accent 6;\lsdpriority50 \lsdlocked0 Grid Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 Grid Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 Grid Table 7 Colorful Accent 6;
|
||||||
|
\lsdpriority46 \lsdlocked0 List Table 1 Light;\lsdpriority47 \lsdlocked0 List Table 2;\lsdpriority48 \lsdlocked0 List Table 3;\lsdpriority49 \lsdlocked0 List Table 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark;
|
||||||
|
\lsdpriority51 \lsdlocked0 List Table 6 Colorful;\lsdpriority52 \lsdlocked0 List Table 7 Colorful;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 1;\lsdpriority47 \lsdlocked0 List Table 2 Accent 1;\lsdpriority48 \lsdlocked0 List Table 3 Accent 1;
|
||||||
|
\lsdpriority49 \lsdlocked0 List Table 4 Accent 1;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 1;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 1;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 1;
|
||||||
|
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 2;\lsdpriority47 \lsdlocked0 List Table 2 Accent 2;\lsdpriority48 \lsdlocked0 List Table 3 Accent 2;\lsdpriority49 \lsdlocked0 List Table 4 Accent 2;
|
||||||
|
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 2;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 2;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 2;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 3;
|
||||||
|
\lsdpriority47 \lsdlocked0 List Table 2 Accent 3;\lsdpriority48 \lsdlocked0 List Table 3 Accent 3;\lsdpriority49 \lsdlocked0 List Table 4 Accent 3;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 3;
|
||||||
|
\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 3;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 3;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 4;\lsdpriority47 \lsdlocked0 List Table 2 Accent 4;
|
||||||
|
\lsdpriority48 \lsdlocked0 List Table 3 Accent 4;\lsdpriority49 \lsdlocked0 List Table 4 Accent 4;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 4;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 4;
|
||||||
|
\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 4;\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 5;\lsdpriority47 \lsdlocked0 List Table 2 Accent 5;\lsdpriority48 \lsdlocked0 List Table 3 Accent 5;
|
||||||
|
\lsdpriority49 \lsdlocked0 List Table 4 Accent 5;\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 5;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 5;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 5;
|
||||||
|
\lsdpriority46 \lsdlocked0 List Table 1 Light Accent 6;\lsdpriority47 \lsdlocked0 List Table 2 Accent 6;\lsdpriority48 \lsdlocked0 List Table 3 Accent 6;\lsdpriority49 \lsdlocked0 List Table 4 Accent 6;
|
||||||
|
\lsdpriority50 \lsdlocked0 List Table 5 Dark Accent 6;\lsdpriority51 \lsdlocked0 List Table 6 Colorful Accent 6;\lsdpriority52 \lsdlocked0 List Table 7 Colorful Accent 6;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Mention;
|
||||||
|
\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Hyperlink;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Hashtag;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Unresolved Mention;\lsdsemihidden1 \lsdunhideused1 \lsdlocked0 Smart Link;}}{\*\datastore 01050000
|
||||||
|
02000000180000004d73786d6c322e534158584d4c5265616465722e362e3000000000000000000000060000
|
||||||
|
d0cf11e0a1b11ae1000000000000000000000000000000003e000300feff090006000000000000000000000001000000010000000000000000100000feffffff00000000feffffff0000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
fffffffffffffffffdfffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||||
|
ffffffffffffffffffffffffffffffff52006f006f007400200045006e00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000500ffffffffffffffffffffffff0c6ad98892f1d411a65f0040963251e5000000000000000000000000f0af
|
||||||
|
5b31897bdb01feffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000
|
||||||
|
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000
|
||||||
|
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffff000000000000000000000000000000000000000000000000
|
||||||
|
0000000000000000000000000000000000000000000000000105000000000000}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3 -m pytest
|
||||||
|
import os
|
||||||
|
|
||||||
|
from markitdown import MarkItDown, StreamInfo
|
||||||
|
from markitdown_sample_plugin import RtfConverter
|
||||||
|
|
||||||
|
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "test_files")
|
||||||
|
|
||||||
|
RTF_TEST_STRINGS = {
|
||||||
|
"This is a Sample RTF File",
|
||||||
|
"It is included to test if the MarkItDown sample plugin can correctly convert RTF files.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_converter() -> None:
|
||||||
|
"""Tests the RTF converter dirctly."""
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, "test.rtf"), "rb") as file_stream:
|
||||||
|
converter = RtfConverter()
|
||||||
|
result = converter.convert(
|
||||||
|
file_stream=file_stream,
|
||||||
|
stream_info=StreamInfo(
|
||||||
|
mimetype="text/rtf", extension=".rtf", filename="test.rtf"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for test_string in RTF_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_markitdown() -> None:
|
||||||
|
"""Tests that MarkItDown correctly loads the plugin."""
|
||||||
|
md = MarkItDown(enable_plugins=True)
|
||||||
|
result = md.convert(os.path.join(TEST_FILES_DIR, "test.rtf"))
|
||||||
|
|
||||||
|
for test_string in RTF_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
"""Runs this file's tests from the command line."""
|
||||||
|
test_converter()
|
||||||
|
test_markitdown()
|
||||||
|
print("All tests passed.")
|
||||||
52
packages/markitdown/README.md
Normal file
52
packages/markitdown/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# MarkItDown
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> MarkItDown is a Python package and command-line utility for converting various files to Markdown (e.g., for indexing, text analysis, etc).
|
||||||
|
>
|
||||||
|
> For more information, and full documentation, see the project [README.md](https://github.com/microsoft/markitdown) on GitHub.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
From PyPI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install markitdown[all]
|
||||||
|
```
|
||||||
|
|
||||||
|
From source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:microsoft/markitdown.git
|
||||||
|
cd markitdown
|
||||||
|
pip install -e packages/markitdown[all]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Command-Line
|
||||||
|
|
||||||
|
```bash
|
||||||
|
markitdown path-to-file.pdf > document.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from markitdown import MarkItDown
|
||||||
|
|
||||||
|
md = MarkItDown()
|
||||||
|
result = md.convert("test.xlsx")
|
||||||
|
print(result.text_content)
|
||||||
|
```
|
||||||
|
|
||||||
|
### More Information
|
||||||
|
|
||||||
|
For more information, and full documentation, see the project [README.md](https://github.com/microsoft/markitdown) on GitHub.
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
|
||||||
|
trademarks or logos is subject to and must follow
|
||||||
|
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
|
||||||
|
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
|
||||||
|
Any use of third-party trademarks or logos are subject to those third-party's policies.
|
||||||
232
packages/markitdown/ThirdPartyNotices.md
Normal file
232
packages/markitdown/ThirdPartyNotices.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
|
||||||
|
|
||||||
|
**Do Not Translate or Localize**
|
||||||
|
|
||||||
|
This project incorporates components from the projects listed below. The original copyright notices and the licenses
|
||||||
|
under which MarkItDown received such components are set forth below. MarkItDown reserves all rights not expressly
|
||||||
|
granted herein, whether by implication, estoppel or otherwise.
|
||||||
|
|
||||||
|
1.dwml (https://github.com/xiilei/dwml)
|
||||||
|
|
||||||
|
dwml NOTICES AND INFORMATION BEGIN HERE
|
||||||
|
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
NOTE 1: What follows is a verbatim copy of dwml's LICENSE file, as it appeared on March 28th, 2025 - including
|
||||||
|
placeholders for the copyright owner and year.
|
||||||
|
|
||||||
|
NOTE 2: The Apache License, Version 2.0, requires that modifications to the dwml source code be documented.
|
||||||
|
The following section summarizes these changes. The full details are available in the MarkItDown source code
|
||||||
|
repository under PR #1160 (https://github.com/microsoft/markitdown/pull/1160)
|
||||||
|
|
||||||
|
This project incorporates `dwml/latex_dict.py` and `dwml/omml.py` files without any additional logic modifications (which
|
||||||
|
lives in `packages/markitdown/src/markitdown/converter_utils/docx/math` location). However, we have reformatted the code
|
||||||
|
according to `black` code formatter. From `tests/docx.py` file, we have used `DOCXML_ROOT` XML namespaces and the rest of
|
||||||
|
the file is not used.
|
||||||
|
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright {yyyy} {name of copyright owner}
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
-----------------------------------------
|
||||||
|
END OF dwml NOTICES AND INFORMATION
|
||||||
@@ -26,25 +26,38 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
"requests",
|
"requests",
|
||||||
"mammoth",
|
|
||||||
"markdownify",
|
"markdownify",
|
||||||
"numpy",
|
"magika~=0.6.1",
|
||||||
|
"charset-normalizer",
|
||||||
|
"defusedxml",
|
||||||
|
"onnxruntime<=1.20.1; sys_platform == 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
all = [
|
||||||
"python-pptx",
|
"python-pptx",
|
||||||
|
"mammoth~=1.10.0",
|
||||||
"pandas",
|
"pandas",
|
||||||
"openpyxl",
|
"openpyxl",
|
||||||
"xlrd",
|
"xlrd",
|
||||||
|
"lxml",
|
||||||
"pdfminer.six",
|
"pdfminer.six",
|
||||||
"puremagic",
|
|
||||||
"pydub",
|
|
||||||
"olefile",
|
"olefile",
|
||||||
"youtube-transcript-api",
|
"pydub",
|
||||||
"SpeechRecognition",
|
"SpeechRecognition",
|
||||||
"pathvalidate",
|
"youtube-transcript-api~=1.0.0",
|
||||||
"charset-normalizer",
|
|
||||||
"openai",
|
|
||||||
"azure-ai-documentintelligence",
|
"azure-ai-documentintelligence",
|
||||||
"azure-identity"
|
"azure-identity"
|
||||||
]
|
]
|
||||||
|
pptx = ["python-pptx"]
|
||||||
|
docx = ["mammoth", "lxml"]
|
||||||
|
xlsx = ["pandas", "openpyxl"]
|
||||||
|
xls = ["pandas", "xlrd"]
|
||||||
|
pdf = ["pdfminer.six"]
|
||||||
|
outlook = ["olefile"]
|
||||||
|
audio-transcription = ["pydub", "SpeechRecognition"]
|
||||||
|
youtube-transcription = ["youtube-transcript-api"]
|
||||||
|
az-doc-intel = ["azure-ai-documentintelligence", "azure-identity"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Documentation = "https://github.com/microsoft/markitdown#readme"
|
Documentation = "https://github.com/microsoft/markitdown#readme"
|
||||||
@@ -57,12 +70,24 @@ path = "src/markitdown/__about__.py"
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
markitdown = "markitdown.__main__:main"
|
markitdown = "markitdown.__main__:main"
|
||||||
|
|
||||||
[tool.hatch.envs.types]
|
[tool.hatch.envs.default]
|
||||||
|
features = ["all"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.hatch-test]
|
||||||
|
features = ["all"]
|
||||||
extra-dependencies = [
|
extra-dependencies = [
|
||||||
|
"openai",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.envs.types]
|
||||||
|
features = ["all"]
|
||||||
|
extra-dependencies = [
|
||||||
|
"openai",
|
||||||
"mypy>=1.0.0",
|
"mypy>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.hatch.envs.types.scripts]
|
[tool.hatch.envs.types.scripts]
|
||||||
check = "mypy --install-types --non-interactive {args:src/markitdown tests}"
|
check = "mypy --install-types --non-interactive --ignore-missing-imports {args:src/markitdown tests}"
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source_pkgs = ["markitdown", "tests"]
|
source_pkgs = ["markitdown", "tests"]
|
||||||
4
packages/markitdown/src/markitdown/__about__.py
Normal file
4
packages/markitdown/src/markitdown/__about__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
__version__ = "0.1.3"
|
||||||
34
packages/markitdown/src/markitdown/__init__.py
Normal file
34
packages/markitdown/src/markitdown/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from .__about__ import __version__
|
||||||
|
from ._markitdown import (
|
||||||
|
MarkItDown,
|
||||||
|
PRIORITY_SPECIFIC_FILE_FORMAT,
|
||||||
|
PRIORITY_GENERIC_FILE_FORMAT,
|
||||||
|
)
|
||||||
|
from ._base_converter import DocumentConverterResult, DocumentConverter
|
||||||
|
from ._stream_info import StreamInfo
|
||||||
|
from ._exceptions import (
|
||||||
|
MarkItDownException,
|
||||||
|
MissingDependencyException,
|
||||||
|
FailedConversionAttempt,
|
||||||
|
FileConversionException,
|
||||||
|
UnsupportedFormatException,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"MarkItDown",
|
||||||
|
"DocumentConverter",
|
||||||
|
"DocumentConverterResult",
|
||||||
|
"MarkItDownException",
|
||||||
|
"MissingDependencyException",
|
||||||
|
"FailedConversionAttempt",
|
||||||
|
"FileConversionException",
|
||||||
|
"UnsupportedFormatException",
|
||||||
|
"StreamInfo",
|
||||||
|
"PRIORITY_SPECIFIC_FILE_FORMAT",
|
||||||
|
"PRIORITY_GENERIC_FILE_FORMAT",
|
||||||
|
]
|
||||||
223
packages/markitdown/src/markitdown/__main__.py
Normal file
223
packages/markitdown/src/markitdown/__main__.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import codecs
|
||||||
|
from textwrap import dedent
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
from .__about__ import __version__
|
||||||
|
from ._markitdown import MarkItDown, StreamInfo, DocumentConverterResult
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Convert various file formats to markdown.",
|
||||||
|
prog="markitdown",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
usage=dedent(
|
||||||
|
"""
|
||||||
|
SYNTAX:
|
||||||
|
|
||||||
|
markitdown <OPTIONAL: FILENAME>
|
||||||
|
If FILENAME is empty, markitdown reads from stdin.
|
||||||
|
|
||||||
|
EXAMPLE:
|
||||||
|
|
||||||
|
markitdown example.pdf
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
cat example.pdf | markitdown
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
markitdown < example.pdf
|
||||||
|
|
||||||
|
OR to save to a file use
|
||||||
|
|
||||||
|
markitdown example.pdf -o example.md
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
markitdown example.pdf > example.md
|
||||||
|
"""
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {__version__}",
|
||||||
|
help="show the version number and exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="Output file name. If not provided, output is written to stdout.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-x",
|
||||||
|
"--extension",
|
||||||
|
help="Provide a hint about the file extension (e.g., when reading from stdin).",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-m",
|
||||||
|
"--mime-type",
|
||||||
|
help="Provide a hint about the file's MIME type.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--charset",
|
||||||
|
help="Provide a hint about the file's charset (e.g, UTF-8).",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--use-docintel",
|
||||||
|
action="store_true",
|
||||||
|
help="Use Document Intelligence to extract text instead of offline conversion. Requires a valid Document Intelligence Endpoint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-e",
|
||||||
|
"--endpoint",
|
||||||
|
type=str,
|
||||||
|
help="Document Intelligence Endpoint. Required if using Document Intelligence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--use-plugins",
|
||||||
|
action="store_true",
|
||||||
|
help="Use 3rd-party plugins to convert files. Use --list-plugins to see installed plugins.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-plugins",
|
||||||
|
action="store_true",
|
||||||
|
help="List installed 3rd-party plugins. Plugins are loaded when using the -p or --use-plugin option.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-data-uris",
|
||||||
|
action="store_true",
|
||||||
|
help="Keep data URIs (like base64-encoded images) in the output. By default, data URIs are truncated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("filename", nargs="?")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Parse the extension hint
|
||||||
|
extension_hint = args.extension
|
||||||
|
if extension_hint is not None:
|
||||||
|
extension_hint = extension_hint.strip().lower()
|
||||||
|
if len(extension_hint) > 0:
|
||||||
|
if not extension_hint.startswith("."):
|
||||||
|
extension_hint = "." + extension_hint
|
||||||
|
else:
|
||||||
|
extension_hint = None
|
||||||
|
|
||||||
|
# Parse the mime type
|
||||||
|
mime_type_hint = args.mime_type
|
||||||
|
if mime_type_hint is not None:
|
||||||
|
mime_type_hint = mime_type_hint.strip()
|
||||||
|
if len(mime_type_hint) > 0:
|
||||||
|
if mime_type_hint.count("/") != 1:
|
||||||
|
_exit_with_error(f"Invalid MIME type: {mime_type_hint}")
|
||||||
|
else:
|
||||||
|
mime_type_hint = None
|
||||||
|
|
||||||
|
# Parse the charset
|
||||||
|
charset_hint = args.charset
|
||||||
|
if charset_hint is not None:
|
||||||
|
charset_hint = charset_hint.strip()
|
||||||
|
if len(charset_hint) > 0:
|
||||||
|
try:
|
||||||
|
charset_hint = codecs.lookup(charset_hint).name
|
||||||
|
except LookupError:
|
||||||
|
_exit_with_error(f"Invalid charset: {charset_hint}")
|
||||||
|
else:
|
||||||
|
charset_hint = None
|
||||||
|
|
||||||
|
stream_info = None
|
||||||
|
if (
|
||||||
|
extension_hint is not None
|
||||||
|
or mime_type_hint is not None
|
||||||
|
or charset_hint is not None
|
||||||
|
):
|
||||||
|
stream_info = StreamInfo(
|
||||||
|
extension=extension_hint, mimetype=mime_type_hint, charset=charset_hint
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.list_plugins:
|
||||||
|
# List installed plugins, then exit
|
||||||
|
print("Installed MarkItDown 3rd-party Plugins:\n")
|
||||||
|
plugin_entry_points = list(entry_points(group="markitdown.plugin"))
|
||||||
|
if len(plugin_entry_points) == 0:
|
||||||
|
print(" * No 3rd-party plugins installed.")
|
||||||
|
print(
|
||||||
|
"\nFind plugins by searching for the hashtag #markitdown-plugin on GitHub.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for entry_point in plugin_entry_points:
|
||||||
|
print(f" * {entry_point.name:<16}\t(package: {entry_point.value})")
|
||||||
|
print(
|
||||||
|
"\nUse the -p (or --use-plugins) option to enable 3rd-party plugins.\n"
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args.use_docintel:
|
||||||
|
if args.endpoint is None:
|
||||||
|
_exit_with_error(
|
||||||
|
"Document Intelligence Endpoint is required when using Document Intelligence."
|
||||||
|
)
|
||||||
|
elif args.filename is None:
|
||||||
|
_exit_with_error("Filename is required when using Document Intelligence.")
|
||||||
|
|
||||||
|
markitdown = MarkItDown(
|
||||||
|
enable_plugins=args.use_plugins, docintel_endpoint=args.endpoint
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
markitdown = MarkItDown(enable_plugins=args.use_plugins)
|
||||||
|
|
||||||
|
if args.filename is None:
|
||||||
|
result = markitdown.convert_stream(
|
||||||
|
sys.stdin.buffer,
|
||||||
|
stream_info=stream_info,
|
||||||
|
keep_data_uris=args.keep_data_uris,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = markitdown.convert(
|
||||||
|
args.filename, stream_info=stream_info, keep_data_uris=args.keep_data_uris
|
||||||
|
)
|
||||||
|
|
||||||
|
_handle_output(args, result)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_output(args, result: DocumentConverterResult):
|
||||||
|
"""Handle output to stdout or file"""
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
f.write(result.markdown)
|
||||||
|
else:
|
||||||
|
# Handle stdout encoding errors more gracefully
|
||||||
|
print(
|
||||||
|
result.markdown.encode(sys.stdout.encoding, errors="replace").decode(
|
||||||
|
sys.stdout.encoding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _exit_with_error(message: str):
|
||||||
|
print(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
105
packages/markitdown/src/markitdown/_base_converter.py
Normal file
105
packages/markitdown/src/markitdown/_base_converter.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from typing import Any, BinaryIO, Optional
|
||||||
|
from ._stream_info import StreamInfo
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentConverterResult:
|
||||||
|
"""The result of converting a document to Markdown."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
markdown: str,
|
||||||
|
*,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the DocumentConverterResult.
|
||||||
|
|
||||||
|
The only required parameter is the converted Markdown text.
|
||||||
|
The title, and any other metadata that may be added in the future, are optional.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- markdown: The converted Markdown text.
|
||||||
|
- title: Optional title of the document.
|
||||||
|
"""
|
||||||
|
self.markdown = markdown
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text_content(self) -> str:
|
||||||
|
"""Soft-deprecated alias for `markdown`. New code should migrate to using `markdown` or __str__."""
|
||||||
|
return self.markdown
|
||||||
|
|
||||||
|
@text_content.setter
|
||||||
|
def text_content(self, markdown: str):
|
||||||
|
"""Soft-deprecated alias for `markdown`. New code should migrate to using `markdown` or __str__."""
|
||||||
|
self.markdown = markdown
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return the converted Markdown text."""
|
||||||
|
return self.markdown
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentConverter:
|
||||||
|
"""Abstract superclass of all DocumentConverters."""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Return a quick determination on if the converter should attempt converting the document.
|
||||||
|
This is primarily based `stream_info` (typically, `stream_info.mimetype`, `stream_info.extension`).
|
||||||
|
In cases where the data is retrieved via HTTP, the `steam_info.url` might also be referenced to
|
||||||
|
make a determination (e.g., special converters for Wikipedia, YouTube etc).
|
||||||
|
Finally, it is conceivable that the `stream_info.filename` might be used to in cases
|
||||||
|
where the filename is well-known (e.g., `Dockerfile`, `Makefile`, etc)
|
||||||
|
|
||||||
|
NOTE: The method signature is designed to match that of the convert() method. This provides some
|
||||||
|
assurance that, if accepts() returns True, the convert() method will also be able to handle the document.
|
||||||
|
|
||||||
|
IMPORTANT: In rare cases, (e.g., OutlookMsgConverter) we need to read more from the stream to make a final
|
||||||
|
determination. Read operations inevitably advances the position in file_stream. In these case, the position
|
||||||
|
MUST be reset it MUST be reset before returning. This is because the convert() method may be called immediately
|
||||||
|
after accepts(), and will expect the file_stream to be at the original position.
|
||||||
|
|
||||||
|
E.g.,
|
||||||
|
cur_pos = file_stream.tell() # Save the current position
|
||||||
|
data = file_stream.read(100) # ... peek at the first 100 bytes, etc.
|
||||||
|
file_stream.seek(cur_pos) # Reset the position to the original position
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- file_stream: The file-like object to convert. Must support seek(), tell(), and read() methods.
|
||||||
|
- stream_info: The StreamInfo object containing metadata about the file (mimetype, extension, charset, set)
|
||||||
|
- kwargs: Additional keyword arguments for the converter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- bool: True if the converter can handle the document, False otherwise.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"The subclass, {type(self).__name__}, must implement the accepts() method to determine if they can handle the document."
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
"""
|
||||||
|
Convert a document to Markdown text.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- file_stream: The file-like object to convert. Must support seek(), tell(), and read() methods.
|
||||||
|
- stream_info: The StreamInfo object containing metadata about the file (mimetype, extension, charset, set)
|
||||||
|
- kwargs: Additional keyword arguments for the converter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- DocumentConverterResult: The result of the conversion, which includes the title and markdown content.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
- FileConversionException: If the mimetype is recognized, but the conversion fails for some other reason.
|
||||||
|
- MissingDependencyException: If the converter requires a dependency that is not installed.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
76
packages/markitdown/src/markitdown/_exceptions.py
Normal file
76
packages/markitdown/src/markitdown/_exceptions.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from typing import Optional, List, Any
|
||||||
|
|
||||||
|
MISSING_DEPENDENCY_MESSAGE = """{converter} recognized the input as a potential {extension} file, but the dependencies needed to read {extension} files have not been installed. To resolve this error, include the optional dependency [{feature}] or [all] when installing MarkItDown. For example:
|
||||||
|
|
||||||
|
* pip install markitdown[{feature}]
|
||||||
|
* pip install markitdown[all]
|
||||||
|
* pip install markitdown[{feature}, ...]
|
||||||
|
* etc."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarkItDownException(Exception):
|
||||||
|
"""
|
||||||
|
Base exception class for MarkItDown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingDependencyException(MarkItDownException):
|
||||||
|
"""
|
||||||
|
Converters shipped with MarkItDown may depend on optional
|
||||||
|
dependencies. This exception is thrown when a converter's
|
||||||
|
convert() method is called, but the required dependency is not
|
||||||
|
installed. This is not necessarily a fatal error, as the converter
|
||||||
|
will simply be skipped (an error will bubble up only if no other
|
||||||
|
suitable converter is found).
|
||||||
|
|
||||||
|
Error messages should clearly indicate which dependency is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedFormatException(MarkItDownException):
|
||||||
|
"""
|
||||||
|
Thrown when no suitable converter was found for the given file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FailedConversionAttempt(object):
|
||||||
|
"""
|
||||||
|
Represents an a single attempt to convert a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, converter: Any, exc_info: Optional[tuple] = None):
|
||||||
|
self.converter = converter
|
||||||
|
self.exc_info = exc_info
|
||||||
|
|
||||||
|
|
||||||
|
class FileConversionException(MarkItDownException):
|
||||||
|
"""
|
||||||
|
Thrown when a suitable converter was found, but the conversion
|
||||||
|
process fails for any reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
attempts: Optional[List[FailedConversionAttempt]] = None,
|
||||||
|
):
|
||||||
|
self.attempts = attempts
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
if attempts is None:
|
||||||
|
message = "File conversion failed."
|
||||||
|
else:
|
||||||
|
message = f"File conversion failed after {len(attempts)} attempts:\n"
|
||||||
|
for attempt in attempts:
|
||||||
|
if attempt.exc_info is None:
|
||||||
|
message += f" - {type(attempt.converter).__name__} provided no execution info."
|
||||||
|
else:
|
||||||
|
message += f" - {type(attempt.converter).__name__} threw {attempt.exc_info[0].__name__} with message: {attempt.exc_info[1]}\n"
|
||||||
|
|
||||||
|
super().__init__(message)
|
||||||
776
packages/markitdown/src/markitdown/_markitdown.py
Normal file
776
packages/markitdown/src/markitdown/_markitdown.py
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import traceback
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
from typing import Any, List, Dict, Optional, Union, BinaryIO
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from warnings import warn
|
||||||
|
import requests
|
||||||
|
import magika
|
||||||
|
import charset_normalizer
|
||||||
|
import codecs
|
||||||
|
|
||||||
|
from ._stream_info import StreamInfo
|
||||||
|
from ._uri_utils import parse_data_uri, file_uri_to_path
|
||||||
|
|
||||||
|
from .converters import (
|
||||||
|
PlainTextConverter,
|
||||||
|
HtmlConverter,
|
||||||
|
RssConverter,
|
||||||
|
WikipediaConverter,
|
||||||
|
YouTubeConverter,
|
||||||
|
IpynbConverter,
|
||||||
|
BingSerpConverter,
|
||||||
|
PdfConverter,
|
||||||
|
DocxConverter,
|
||||||
|
XlsxConverter,
|
||||||
|
XlsConverter,
|
||||||
|
PptxConverter,
|
||||||
|
ImageConverter,
|
||||||
|
AudioConverter,
|
||||||
|
OutlookMsgConverter,
|
||||||
|
ZipConverter,
|
||||||
|
EpubConverter,
|
||||||
|
DocumentIntelligenceConverter,
|
||||||
|
CsvConverter,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
|
||||||
|
from ._exceptions import (
|
||||||
|
FileConversionException,
|
||||||
|
UnsupportedFormatException,
|
||||||
|
FailedConversionAttempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Lower priority values are tried first.
|
||||||
|
PRIORITY_SPECIFIC_FILE_FORMAT = (
|
||||||
|
0.0 # e.g., .docx, .pdf, .xlsx, Or specific pages, e.g., wikipedia
|
||||||
|
)
|
||||||
|
PRIORITY_GENERIC_FILE_FORMAT = (
|
||||||
|
10.0 # Near catch-all converters for mimetypes like text/*, etc.
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_plugins: Union[None, List[Any]] = None # If None, plugins have not been loaded yet.
|
||||||
|
|
||||||
|
|
||||||
|
def _load_plugins() -> Union[None, List[Any]]:
|
||||||
|
"""Lazy load plugins, exiting early if already loaded."""
|
||||||
|
global _plugins
|
||||||
|
|
||||||
|
# Skip if we've already loaded plugins
|
||||||
|
if _plugins is not None:
|
||||||
|
return _plugins
|
||||||
|
|
||||||
|
# Load plugins
|
||||||
|
_plugins = []
|
||||||
|
for entry_point in entry_points(group="markitdown.plugin"):
|
||||||
|
try:
|
||||||
|
_plugins.append(entry_point.load())
|
||||||
|
except Exception:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
warn(f"Plugin '{entry_point.name}' failed to load ... skipping:\n{tb}")
|
||||||
|
|
||||||
|
return _plugins
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True)
|
||||||
|
class ConverterRegistration:
|
||||||
|
"""A registration of a converter with its priority and other metadata."""
|
||||||
|
|
||||||
|
converter: DocumentConverter
|
||||||
|
priority: float
|
||||||
|
|
||||||
|
|
||||||
|
class MarkItDown:
|
||||||
|
"""(In preview) An extremely simple text-based document reader, suitable for LLM use.
|
||||||
|
This reader will convert common file-types or webpages to Markdown."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enable_builtins: Union[None, bool] = None,
|
||||||
|
enable_plugins: Union[None, bool] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self._builtins_enabled = False
|
||||||
|
self._plugins_enabled = False
|
||||||
|
|
||||||
|
requests_session = kwargs.get("requests_session")
|
||||||
|
if requests_session is None:
|
||||||
|
self._requests_session = requests.Session()
|
||||||
|
else:
|
||||||
|
self._requests_session = requests_session
|
||||||
|
|
||||||
|
self._magika = magika.Magika()
|
||||||
|
|
||||||
|
# TODO - remove these (see enable_builtins)
|
||||||
|
self._llm_client: Any = None
|
||||||
|
self._llm_model: Union[str | None] = None
|
||||||
|
self._llm_prompt: Union[str | None] = None
|
||||||
|
self._exiftool_path: Union[str | None] = None
|
||||||
|
self._style_map: Union[str | None] = None
|
||||||
|
|
||||||
|
# Register the converters
|
||||||
|
self._converters: List[ConverterRegistration] = []
|
||||||
|
|
||||||
|
if (
|
||||||
|
enable_builtins is None or enable_builtins
|
||||||
|
): # Default to True when not specified
|
||||||
|
self.enable_builtins(**kwargs)
|
||||||
|
|
||||||
|
if enable_plugins:
|
||||||
|
self.enable_plugins(**kwargs)
|
||||||
|
|
||||||
|
def enable_builtins(self, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
Enable and register built-in converters.
|
||||||
|
Built-in converters are enabled by default.
|
||||||
|
This method should only be called once, if built-ins were initially disabled.
|
||||||
|
"""
|
||||||
|
if not self._builtins_enabled:
|
||||||
|
# TODO: Move these into converter constructors
|
||||||
|
self._llm_client = kwargs.get("llm_client")
|
||||||
|
self._llm_model = kwargs.get("llm_model")
|
||||||
|
self._llm_prompt = kwargs.get("llm_prompt")
|
||||||
|
self._exiftool_path = kwargs.get("exiftool_path")
|
||||||
|
self._style_map = kwargs.get("style_map")
|
||||||
|
|
||||||
|
if self._exiftool_path is None:
|
||||||
|
self._exiftool_path = os.getenv("EXIFTOOL_PATH")
|
||||||
|
|
||||||
|
# Still none? Check well-known paths
|
||||||
|
if self._exiftool_path is None:
|
||||||
|
candidate = shutil.which("exiftool")
|
||||||
|
if candidate:
|
||||||
|
candidate = os.path.abspath(candidate)
|
||||||
|
if any(
|
||||||
|
d == os.path.dirname(candidate)
|
||||||
|
for d in [
|
||||||
|
"/usr/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/opt",
|
||||||
|
"/opt/bin",
|
||||||
|
"/opt/local/bin",
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"C:\\Windows\\System32",
|
||||||
|
"C:\\Program Files",
|
||||||
|
"C:\\Program Files (x86)",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
self._exiftool_path = candidate
|
||||||
|
|
||||||
|
# Register converters for successful browsing operations
|
||||||
|
# Later registrations are tried first / take higher priority than earlier registrations
|
||||||
|
# To this end, the most specific converters should appear below the most generic converters
|
||||||
|
self.register_converter(
|
||||||
|
PlainTextConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
|
||||||
|
)
|
||||||
|
self.register_converter(
|
||||||
|
ZipConverter(markitdown=self), priority=PRIORITY_GENERIC_FILE_FORMAT
|
||||||
|
)
|
||||||
|
self.register_converter(
|
||||||
|
HtmlConverter(), priority=PRIORITY_GENERIC_FILE_FORMAT
|
||||||
|
)
|
||||||
|
self.register_converter(RssConverter())
|
||||||
|
self.register_converter(WikipediaConverter())
|
||||||
|
self.register_converter(YouTubeConverter())
|
||||||
|
self.register_converter(BingSerpConverter())
|
||||||
|
self.register_converter(DocxConverter())
|
||||||
|
self.register_converter(XlsxConverter())
|
||||||
|
self.register_converter(XlsConverter())
|
||||||
|
self.register_converter(PptxConverter())
|
||||||
|
self.register_converter(AudioConverter())
|
||||||
|
self.register_converter(ImageConverter())
|
||||||
|
self.register_converter(IpynbConverter())
|
||||||
|
self.register_converter(PdfConverter())
|
||||||
|
self.register_converter(OutlookMsgConverter())
|
||||||
|
self.register_converter(EpubConverter())
|
||||||
|
self.register_converter(CsvConverter())
|
||||||
|
|
||||||
|
# Register Document Intelligence converter at the top of the stack if endpoint is provided
|
||||||
|
docintel_endpoint = kwargs.get("docintel_endpoint")
|
||||||
|
if docintel_endpoint is not None:
|
||||||
|
docintel_args: Dict[str, Any] = {}
|
||||||
|
docintel_args["endpoint"] = docintel_endpoint
|
||||||
|
|
||||||
|
docintel_credential = kwargs.get("docintel_credential")
|
||||||
|
if docintel_credential is not None:
|
||||||
|
docintel_args["credential"] = docintel_credential
|
||||||
|
|
||||||
|
docintel_types = kwargs.get("docintel_file_types")
|
||||||
|
if docintel_types is not None:
|
||||||
|
docintel_args["file_types"] = docintel_types
|
||||||
|
|
||||||
|
docintel_version = kwargs.get("docintel_api_version")
|
||||||
|
if docintel_version is not None:
|
||||||
|
docintel_args["api_version"] = docintel_version
|
||||||
|
|
||||||
|
self.register_converter(
|
||||||
|
DocumentIntelligenceConverter(**docintel_args),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._builtins_enabled = True
|
||||||
|
else:
|
||||||
|
warn("Built-in converters are already enabled.", RuntimeWarning)
|
||||||
|
|
||||||
|
def enable_plugins(self, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
Enable and register converters provided by plugins.
|
||||||
|
Plugins are disabled by default.
|
||||||
|
This method should only be called once, if plugins were initially disabled.
|
||||||
|
"""
|
||||||
|
if not self._plugins_enabled:
|
||||||
|
# Load plugins
|
||||||
|
plugins = _load_plugins()
|
||||||
|
assert plugins is not None
|
||||||
|
for plugin in plugins:
|
||||||
|
try:
|
||||||
|
plugin.register_converters(self, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
warn(f"Plugin '{plugin}' failed to register converters:\n{tb}")
|
||||||
|
self._plugins_enabled = True
|
||||||
|
else:
|
||||||
|
warn("Plugins converters are already enabled.", RuntimeWarning)
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
source: Union[str, requests.Response, Path, BinaryIO],
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult: # TODO: deal with kwargs
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
- source: can be a path (str or Path), url, or a requests.response object
|
||||||
|
- stream_info: optional stream info to use for the conversion. If None, infer from source
|
||||||
|
- kwargs: additional arguments to pass to the converter
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Local path or url
|
||||||
|
if isinstance(source, str):
|
||||||
|
if (
|
||||||
|
source.startswith("http:")
|
||||||
|
or source.startswith("https:")
|
||||||
|
or source.startswith("file:")
|
||||||
|
or source.startswith("data:")
|
||||||
|
):
|
||||||
|
# Rename the url argument to mock_url
|
||||||
|
# (Deprecated -- use stream_info)
|
||||||
|
_kwargs = {k: v for k, v in kwargs.items()}
|
||||||
|
if "url" in _kwargs:
|
||||||
|
_kwargs["mock_url"] = _kwargs["url"]
|
||||||
|
del _kwargs["url"]
|
||||||
|
|
||||||
|
return self.convert_uri(source, stream_info=stream_info, **_kwargs)
|
||||||
|
else:
|
||||||
|
return self.convert_local(source, stream_info=stream_info, **kwargs)
|
||||||
|
# Path object
|
||||||
|
elif isinstance(source, Path):
|
||||||
|
return self.convert_local(source, stream_info=stream_info, **kwargs)
|
||||||
|
# Request response
|
||||||
|
elif isinstance(source, requests.Response):
|
||||||
|
return self.convert_response(source, stream_info=stream_info, **kwargs)
|
||||||
|
# Binary stream
|
||||||
|
elif (
|
||||||
|
hasattr(source, "read")
|
||||||
|
and callable(source.read)
|
||||||
|
and not isinstance(source, io.TextIOBase)
|
||||||
|
):
|
||||||
|
return self.convert_stream(source, stream_info=stream_info, **kwargs)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"Invalid source type: {type(source)}. Expected str, requests.Response, BinaryIO."
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_local(
|
||||||
|
self,
|
||||||
|
path: Union[str, Path],
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
url: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
if isinstance(path, Path):
|
||||||
|
path = str(path)
|
||||||
|
|
||||||
|
# Build a base StreamInfo object from which to start guesses
|
||||||
|
base_guess = StreamInfo(
|
||||||
|
local_path=path,
|
||||||
|
extension=os.path.splitext(path)[1],
|
||||||
|
filename=os.path.basename(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extend the base_guess with any additional info from the arguments
|
||||||
|
if stream_info is not None:
|
||||||
|
base_guess = base_guess.copy_and_update(stream_info)
|
||||||
|
|
||||||
|
if file_extension is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
base_guess = base_guess.copy_and_update(extension=file_extension)
|
||||||
|
|
||||||
|
if url is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
base_guess = base_guess.copy_and_update(url=url)
|
||||||
|
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
guesses = self._get_stream_info_guesses(
|
||||||
|
file_stream=fh, base_guess=base_guess
|
||||||
|
)
|
||||||
|
return self._convert(file_stream=fh, stream_info_guesses=guesses, **kwargs)
|
||||||
|
|
||||||
|
def convert_stream(
|
||||||
|
self,
|
||||||
|
stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
url: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
guesses: List[StreamInfo] = []
|
||||||
|
|
||||||
|
# Do we have anything on which to base a guess?
|
||||||
|
base_guess = None
|
||||||
|
if stream_info is not None or file_extension is not None or url is not None:
|
||||||
|
# Start with a non-Null base guess
|
||||||
|
if stream_info is None:
|
||||||
|
base_guess = StreamInfo()
|
||||||
|
else:
|
||||||
|
base_guess = stream_info
|
||||||
|
|
||||||
|
if file_extension is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
assert base_guess is not None # for mypy
|
||||||
|
base_guess = base_guess.copy_and_update(extension=file_extension)
|
||||||
|
|
||||||
|
if url is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
assert base_guess is not None # for mypy
|
||||||
|
base_guess = base_guess.copy_and_update(url=url)
|
||||||
|
|
||||||
|
# Check if we have a seekable stream. If not, load the entire stream into memory.
|
||||||
|
if not stream.seekable():
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.write(chunk)
|
||||||
|
buffer.seek(0)
|
||||||
|
stream = buffer
|
||||||
|
|
||||||
|
# Add guesses based on stream content
|
||||||
|
guesses = self._get_stream_info_guesses(
|
||||||
|
file_stream=stream, base_guess=base_guess or StreamInfo()
|
||||||
|
)
|
||||||
|
return self._convert(file_stream=stream, stream_info_guesses=guesses, **kwargs)
|
||||||
|
|
||||||
|
def convert_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
file_extension: Optional[str] = None,
|
||||||
|
mock_url: Optional[str] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
"""Alias for convert_uri()"""
|
||||||
|
# convert_url will likely be deprecated in the future in favor of convert_uri
|
||||||
|
return self.convert_uri(
|
||||||
|
url,
|
||||||
|
stream_info=stream_info,
|
||||||
|
file_extension=file_extension,
|
||||||
|
mock_url=mock_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_uri(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
mock_url: Optional[
|
||||||
|
str
|
||||||
|
] = None, # Mock the request as if it came from a different URL
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
uri = uri.strip()
|
||||||
|
|
||||||
|
# File URIs
|
||||||
|
if uri.startswith("file:"):
|
||||||
|
netloc, path = file_uri_to_path(uri)
|
||||||
|
if netloc and netloc != "localhost":
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported file URI: {uri}. Netloc must be empty or localhost."
|
||||||
|
)
|
||||||
|
return self.convert_local(
|
||||||
|
path,
|
||||||
|
stream_info=stream_info,
|
||||||
|
file_extension=file_extension,
|
||||||
|
url=mock_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
# Data URIs
|
||||||
|
elif uri.startswith("data:"):
|
||||||
|
mimetype, attributes, data = parse_data_uri(uri)
|
||||||
|
|
||||||
|
base_guess = StreamInfo(
|
||||||
|
mimetype=mimetype,
|
||||||
|
charset=attributes.get("charset"),
|
||||||
|
)
|
||||||
|
if stream_info is not None:
|
||||||
|
base_guess = base_guess.copy_and_update(stream_info)
|
||||||
|
|
||||||
|
return self.convert_stream(
|
||||||
|
io.BytesIO(data),
|
||||||
|
stream_info=base_guess,
|
||||||
|
file_extension=file_extension,
|
||||||
|
url=mock_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
# HTTP/HTTPS URIs
|
||||||
|
elif uri.startswith("http:") or uri.startswith("https:"):
|
||||||
|
response = self._requests_session.get(uri, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
return self.convert_response(
|
||||||
|
response,
|
||||||
|
stream_info=stream_info,
|
||||||
|
file_extension=file_extension,
|
||||||
|
url=mock_url,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported URI scheme: {uri.split(':')[0]}. Supported schemes are: file:, data:, http:, https:"
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_response(
|
||||||
|
self,
|
||||||
|
response: requests.Response,
|
||||||
|
*,
|
||||||
|
stream_info: Optional[StreamInfo] = None,
|
||||||
|
file_extension: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
url: Optional[str] = None, # Deprecated -- use stream_info
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# If there is a content-type header, get the mimetype and charset (if present)
|
||||||
|
mimetype: Optional[str] = None
|
||||||
|
charset: Optional[str] = None
|
||||||
|
|
||||||
|
if "content-type" in response.headers:
|
||||||
|
parts = response.headers["content-type"].split(";")
|
||||||
|
mimetype = parts.pop(0).strip()
|
||||||
|
for part in parts:
|
||||||
|
if part.strip().startswith("charset="):
|
||||||
|
_charset = part.split("=")[1].strip()
|
||||||
|
if len(_charset) > 0:
|
||||||
|
charset = _charset
|
||||||
|
|
||||||
|
# If there is a content-disposition header, get the filename and possibly the extension
|
||||||
|
filename: Optional[str] = None
|
||||||
|
extension: Optional[str] = None
|
||||||
|
if "content-disposition" in response.headers:
|
||||||
|
m = re.search(r"filename=([^;]+)", response.headers["content-disposition"])
|
||||||
|
if m:
|
||||||
|
filename = m.group(1).strip("\"'")
|
||||||
|
_, _extension = os.path.splitext(filename)
|
||||||
|
if len(_extension) > 0:
|
||||||
|
extension = _extension
|
||||||
|
|
||||||
|
# If there is still no filename, try to read it from the url
|
||||||
|
if filename is None:
|
||||||
|
parsed_url = urlparse(response.url)
|
||||||
|
_, _extension = os.path.splitext(parsed_url.path)
|
||||||
|
if len(_extension) > 0: # Looks like this might be a file!
|
||||||
|
filename = os.path.basename(parsed_url.path)
|
||||||
|
extension = _extension
|
||||||
|
|
||||||
|
# Create an initial guess from all this information
|
||||||
|
base_guess = StreamInfo(
|
||||||
|
mimetype=mimetype,
|
||||||
|
charset=charset,
|
||||||
|
filename=filename,
|
||||||
|
extension=extension,
|
||||||
|
url=response.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update with any additional info from the arguments
|
||||||
|
if stream_info is not None:
|
||||||
|
base_guess = base_guess.copy_and_update(stream_info)
|
||||||
|
if file_extension is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
base_guess = base_guess.copy_and_update(extension=file_extension)
|
||||||
|
if url is not None:
|
||||||
|
# Deprecated -- use stream_info
|
||||||
|
base_guess = base_guess.copy_and_update(url=url)
|
||||||
|
|
||||||
|
# Read into BytesIO
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
for chunk in response.iter_content(chunk_size=512):
|
||||||
|
buffer.write(chunk)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
# Convert
|
||||||
|
guesses = self._get_stream_info_guesses(
|
||||||
|
file_stream=buffer, base_guess=base_guess
|
||||||
|
)
|
||||||
|
return self._convert(file_stream=buffer, stream_info_guesses=guesses, **kwargs)
|
||||||
|
|
||||||
|
def _convert(
|
||||||
|
self, *, file_stream: BinaryIO, stream_info_guesses: List[StreamInfo], **kwargs
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
res: Union[None, DocumentConverterResult] = None
|
||||||
|
|
||||||
|
# Keep track of which converters throw exceptions
|
||||||
|
failed_attempts: List[FailedConversionAttempt] = []
|
||||||
|
|
||||||
|
# Create a copy of the page_converters list, sorted by priority.
|
||||||
|
# We do this with each call to _convert because the priority of converters may change between calls.
|
||||||
|
# The sort is guaranteed to be stable, so converters with the same priority will remain in the same order.
|
||||||
|
sorted_registrations = sorted(self._converters, key=lambda x: x.priority)
|
||||||
|
|
||||||
|
# Remember the initial stream position so that we can return to it
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
|
||||||
|
for stream_info in stream_info_guesses + [StreamInfo()]:
|
||||||
|
for converter_registration in sorted_registrations:
|
||||||
|
converter = converter_registration.converter
|
||||||
|
# Sanity check -- make sure the cur_pos is still the same
|
||||||
|
assert (
|
||||||
|
cur_pos == file_stream.tell()
|
||||||
|
), "File stream position should NOT change between guess iterations"
|
||||||
|
|
||||||
|
_kwargs = {k: v for k, v in kwargs.items()}
|
||||||
|
|
||||||
|
# Copy any additional global options
|
||||||
|
if "llm_client" not in _kwargs and self._llm_client is not None:
|
||||||
|
_kwargs["llm_client"] = self._llm_client
|
||||||
|
|
||||||
|
if "llm_model" not in _kwargs and self._llm_model is not None:
|
||||||
|
_kwargs["llm_model"] = self._llm_model
|
||||||
|
|
||||||
|
if "llm_prompt" not in _kwargs and self._llm_prompt is not None:
|
||||||
|
_kwargs["llm_prompt"] = self._llm_prompt
|
||||||
|
|
||||||
|
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._converters
|
||||||
|
|
||||||
|
# Add legaxy kwargs
|
||||||
|
if stream_info is not None:
|
||||||
|
if stream_info.extension is not None:
|
||||||
|
_kwargs["file_extension"] = stream_info.extension
|
||||||
|
|
||||||
|
if stream_info.url is not None:
|
||||||
|
_kwargs["url"] = stream_info.url
|
||||||
|
|
||||||
|
# Check if the converter will accept the file, and if so, try to convert it
|
||||||
|
_accepts = False
|
||||||
|
try:
|
||||||
|
_accepts = converter.accepts(file_stream, stream_info, **_kwargs)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# accept() should not have changed the file stream position
|
||||||
|
assert (
|
||||||
|
cur_pos == file_stream.tell()
|
||||||
|
), f"{type(converter).__name__}.accept() should NOT change the file_stream position"
|
||||||
|
|
||||||
|
# Attempt the conversion
|
||||||
|
if _accepts:
|
||||||
|
try:
|
||||||
|
res = converter.convert(file_stream, stream_info, **_kwargs)
|
||||||
|
except Exception:
|
||||||
|
failed_attempts.append(
|
||||||
|
FailedConversionAttempt(
|
||||||
|
converter=converter, exc_info=sys.exc_info()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
if res is not None:
|
||||||
|
# Normalize the content
|
||||||
|
res.text_content = "\n".join(
|
||||||
|
[line.rstrip() for line in re.split(r"\r?\n", res.text_content)]
|
||||||
|
)
|
||||||
|
res.text_content = re.sub(r"\n{3,}", "\n\n", res.text_content)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# If we got this far without success, report any exceptions
|
||||||
|
if len(failed_attempts) > 0:
|
||||||
|
raise FileConversionException(attempts=failed_attempts)
|
||||||
|
|
||||||
|
# Nothing can handle it!
|
||||||
|
raise UnsupportedFormatException(
|
||||||
|
"Could not convert stream to Markdown. No converter attempted a conversion, suggesting that the filetype is simply not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_page_converter(self, converter: DocumentConverter) -> None:
|
||||||
|
"""DEPRECATED: User register_converter instead."""
|
||||||
|
warn(
|
||||||
|
"register_page_converter is deprecated. Use register_converter instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
)
|
||||||
|
self.register_converter(converter)
|
||||||
|
|
||||||
|
def register_converter(
|
||||||
|
self,
|
||||||
|
converter: DocumentConverter,
|
||||||
|
*,
|
||||||
|
priority: float = PRIORITY_SPECIFIC_FILE_FORMAT,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a DocumentConverter with a given priority.
|
||||||
|
|
||||||
|
Priorities work as follows: By default, most converters get priority
|
||||||
|
DocumentConverter.PRIORITY_SPECIFIC_FILE_FORMAT (== 0). The exception
|
||||||
|
is the PlainTextConverter, HtmlConverter, and ZipConverter, which get
|
||||||
|
priority PRIORITY_SPECIFIC_FILE_FORMAT (== 10), with lower values
|
||||||
|
being tried first (i.e., higher priority).
|
||||||
|
|
||||||
|
Just prior to conversion, the converters are sorted by priority, using
|
||||||
|
a stable sort. This means that converters with the same priority will
|
||||||
|
remain in the same order, with the most recently registered converters
|
||||||
|
appearing first.
|
||||||
|
|
||||||
|
We have tight control over the order of built-in converters, but
|
||||||
|
plugins can register converters in any order. The registration's priority
|
||||||
|
field reasserts some control over the order of converters.
|
||||||
|
|
||||||
|
Plugins can register converters with any priority, to appear before or
|
||||||
|
after the built-ins. For example, a plugin with priority 9 will run
|
||||||
|
before the PlainTextConverter, but after the built-in converters.
|
||||||
|
"""
|
||||||
|
self._converters.insert(
|
||||||
|
0, ConverterRegistration(converter=converter, priority=priority)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_stream_info_guesses(
|
||||||
|
self, file_stream: BinaryIO, base_guess: StreamInfo
|
||||||
|
) -> List[StreamInfo]:
|
||||||
|
"""
|
||||||
|
Given a base guess, attempt to guess or expand on the stream info using the stream content (via magika).
|
||||||
|
"""
|
||||||
|
guesses: List[StreamInfo] = []
|
||||||
|
|
||||||
|
# Enhance the base guess with information based on the extension or mimetype
|
||||||
|
enhanced_guess = base_guess.copy_and_update()
|
||||||
|
|
||||||
|
# If there's an extension and no mimetype, try to guess the mimetype
|
||||||
|
if base_guess.mimetype is None and base_guess.extension is not None:
|
||||||
|
_m, _ = mimetypes.guess_type(
|
||||||
|
"placeholder" + base_guess.extension, strict=False
|
||||||
|
)
|
||||||
|
if _m is not None:
|
||||||
|
enhanced_guess = enhanced_guess.copy_and_update(mimetype=_m)
|
||||||
|
|
||||||
|
# If there's a mimetype and no extension, try to guess the extension
|
||||||
|
if base_guess.mimetype is not None and base_guess.extension is None:
|
||||||
|
_e = mimetypes.guess_all_extensions(base_guess.mimetype, strict=False)
|
||||||
|
if len(_e) > 0:
|
||||||
|
enhanced_guess = enhanced_guess.copy_and_update(extension=_e[0])
|
||||||
|
|
||||||
|
# Call magika to guess from the stream
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
result = self._magika.identify_stream(file_stream)
|
||||||
|
if result.status == "ok" and result.prediction.output.label != "unknown":
|
||||||
|
# If it's text, also guess the charset
|
||||||
|
charset = None
|
||||||
|
if result.prediction.output.is_text:
|
||||||
|
# Read the first 4k to guess the charset
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
stream_page = file_stream.read(4096)
|
||||||
|
charset_result = charset_normalizer.from_bytes(stream_page).best()
|
||||||
|
|
||||||
|
if charset_result is not None:
|
||||||
|
charset = self._normalize_charset(charset_result.encoding)
|
||||||
|
|
||||||
|
# Normalize the first extension listed
|
||||||
|
guessed_extension = None
|
||||||
|
if len(result.prediction.output.extensions) > 0:
|
||||||
|
guessed_extension = "." + result.prediction.output.extensions[0]
|
||||||
|
|
||||||
|
# Determine if the guess is compatible with the base guess
|
||||||
|
compatible = True
|
||||||
|
if (
|
||||||
|
base_guess.mimetype is not None
|
||||||
|
and base_guess.mimetype != result.prediction.output.mime_type
|
||||||
|
):
|
||||||
|
compatible = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
base_guess.extension is not None
|
||||||
|
and base_guess.extension.lstrip(".")
|
||||||
|
not in result.prediction.output.extensions
|
||||||
|
):
|
||||||
|
compatible = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
base_guess.charset is not None
|
||||||
|
and self._normalize_charset(base_guess.charset) != charset
|
||||||
|
):
|
||||||
|
compatible = False
|
||||||
|
|
||||||
|
if compatible:
|
||||||
|
# Add the compatible base guess
|
||||||
|
guesses.append(
|
||||||
|
StreamInfo(
|
||||||
|
mimetype=base_guess.mimetype
|
||||||
|
or result.prediction.output.mime_type,
|
||||||
|
extension=base_guess.extension or guessed_extension,
|
||||||
|
charset=base_guess.charset or charset,
|
||||||
|
filename=base_guess.filename,
|
||||||
|
local_path=base_guess.local_path,
|
||||||
|
url=base_guess.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# The magika guess was incompatible with the base guess, so add both guesses
|
||||||
|
guesses.append(enhanced_guess)
|
||||||
|
guesses.append(
|
||||||
|
StreamInfo(
|
||||||
|
mimetype=result.prediction.output.mime_type,
|
||||||
|
extension=guessed_extension,
|
||||||
|
charset=charset,
|
||||||
|
filename=base_guess.filename,
|
||||||
|
local_path=base_guess.local_path,
|
||||||
|
url=base_guess.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# There were no other guesses, so just add the base guess
|
||||||
|
guesses.append(enhanced_guess)
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
return guesses
|
||||||
|
|
||||||
|
def _normalize_charset(self, charset: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Normalize a charset string to a canonical form.
|
||||||
|
"""
|
||||||
|
if charset is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return codecs.lookup(charset).name
|
||||||
|
except LookupError:
|
||||||
|
return charset
|
||||||
32
packages/markitdown/src/markitdown/_stream_info.py
Normal file
32
packages/markitdown/src/markitdown/_stream_info.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True)
|
||||||
|
class StreamInfo:
|
||||||
|
"""The StreamInfo class is used to store information about a file stream.
|
||||||
|
All fields can be None, and will depend on how the stream was opened.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mimetype: Optional[str] = None
|
||||||
|
extension: Optional[str] = None
|
||||||
|
charset: Optional[str] = None
|
||||||
|
filename: Optional[
|
||||||
|
str
|
||||||
|
] = None # From local path, url, or Content-Disposition header
|
||||||
|
local_path: Optional[str] = None # If read from disk
|
||||||
|
url: Optional[str] = None # If read from url
|
||||||
|
|
||||||
|
def copy_and_update(self, *args, **kwargs):
|
||||||
|
"""Copy the StreamInfo object and update it with the given StreamInfo
|
||||||
|
instance and/or other keyword arguments."""
|
||||||
|
new_info = asdict(self)
|
||||||
|
|
||||||
|
for si in args:
|
||||||
|
assert isinstance(si, StreamInfo)
|
||||||
|
new_info.update({k: v for k, v in asdict(si).items() if v is not None})
|
||||||
|
|
||||||
|
if len(kwargs) > 0:
|
||||||
|
new_info.update(kwargs)
|
||||||
|
|
||||||
|
return StreamInfo(**new_info)
|
||||||
52
packages/markitdown/src/markitdown/_uri_utils.py
Normal file
52
packages/markitdown/src/markitdown/_uri_utils.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from typing import Tuple, Dict
|
||||||
|
from urllib.request import url2pathname
|
||||||
|
from urllib.parse import urlparse, unquote_to_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def file_uri_to_path(file_uri: str) -> Tuple[str | None, str]:
|
||||||
|
"""Convert a file URI to a local file path"""
|
||||||
|
parsed = urlparse(file_uri)
|
||||||
|
if parsed.scheme != "file":
|
||||||
|
raise ValueError(f"Not a file URL: {file_uri}")
|
||||||
|
|
||||||
|
netloc = parsed.netloc if parsed.netloc else None
|
||||||
|
path = os.path.abspath(url2pathname(parsed.path))
|
||||||
|
return netloc, path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_data_uri(uri: str) -> Tuple[str | None, Dict[str, str], bytes]:
|
||||||
|
if not uri.startswith("data:"):
|
||||||
|
raise ValueError("Not a data URI")
|
||||||
|
|
||||||
|
header, _, data = uri.partition(",")
|
||||||
|
if not _:
|
||||||
|
raise ValueError("Malformed data URI, missing ',' separator")
|
||||||
|
|
||||||
|
meta = header[5:] # Strip 'data:'
|
||||||
|
parts = meta.split(";")
|
||||||
|
|
||||||
|
is_base64 = False
|
||||||
|
# Ends with base64?
|
||||||
|
if parts[-1] == "base64":
|
||||||
|
parts.pop()
|
||||||
|
is_base64 = True
|
||||||
|
|
||||||
|
mime_type = None # Normally this would default to text/plain but we won't assume
|
||||||
|
if len(parts) and len(parts[0]) > 0:
|
||||||
|
# First part is the mime type
|
||||||
|
mime_type = parts.pop(0)
|
||||||
|
|
||||||
|
attributes: Dict[str, str] = {}
|
||||||
|
for part in parts:
|
||||||
|
# Handle key=value pairs in the middle
|
||||||
|
if "=" in part:
|
||||||
|
key, value = part.split("=", 1)
|
||||||
|
attributes[key] = value
|
||||||
|
elif len(part) > 0:
|
||||||
|
attributes[part] = ""
|
||||||
|
|
||||||
|
content = base64.b64decode(data) if is_base64 else unquote_to_bytes(data)
|
||||||
|
|
||||||
|
return mime_type, attributes, content
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Adapted from https://github.com/xiilei/dwml/blob/master/dwml/latex_dict.py
|
||||||
|
On 25/03/2025
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
CHARS = ("{", "}", "_", "^", "#", "&", "$", "%", "~")
|
||||||
|
|
||||||
|
BLANK = ""
|
||||||
|
BACKSLASH = "\\"
|
||||||
|
ALN = "&"
|
||||||
|
|
||||||
|
CHR = {
|
||||||
|
# Unicode : Latex Math Symbols
|
||||||
|
# Top accents
|
||||||
|
"\u0300": "\\grave{{{0}}}",
|
||||||
|
"\u0301": "\\acute{{{0}}}",
|
||||||
|
"\u0302": "\\hat{{{0}}}",
|
||||||
|
"\u0303": "\\tilde{{{0}}}",
|
||||||
|
"\u0304": "\\bar{{{0}}}",
|
||||||
|
"\u0305": "\\overbar{{{0}}}",
|
||||||
|
"\u0306": "\\breve{{{0}}}",
|
||||||
|
"\u0307": "\\dot{{{0}}}",
|
||||||
|
"\u0308": "\\ddot{{{0}}}",
|
||||||
|
"\u0309": "\\ovhook{{{0}}}",
|
||||||
|
"\u030a": "\\ocirc{{{0}}}}",
|
||||||
|
"\u030c": "\\check{{{0}}}}",
|
||||||
|
"\u0310": "\\candra{{{0}}}",
|
||||||
|
"\u0312": "\\oturnedcomma{{{0}}}",
|
||||||
|
"\u0315": "\\ocommatopright{{{0}}}",
|
||||||
|
"\u031a": "\\droang{{{0}}}",
|
||||||
|
"\u0338": "\\not{{{0}}}",
|
||||||
|
"\u20d0": "\\leftharpoonaccent{{{0}}}",
|
||||||
|
"\u20d1": "\\rightharpoonaccent{{{0}}}",
|
||||||
|
"\u20d2": "\\vertoverlay{{{0}}}",
|
||||||
|
"\u20d6": "\\overleftarrow{{{0}}}",
|
||||||
|
"\u20d7": "\\vec{{{0}}}",
|
||||||
|
"\u20db": "\\dddot{{{0}}}",
|
||||||
|
"\u20dc": "\\ddddot{{{0}}}",
|
||||||
|
"\u20e1": "\\overleftrightarrow{{{0}}}",
|
||||||
|
"\u20e7": "\\annuity{{{0}}}",
|
||||||
|
"\u20e9": "\\widebridgeabove{{{0}}}",
|
||||||
|
"\u20f0": "\\asteraccent{{{0}}}",
|
||||||
|
# Bottom accents
|
||||||
|
"\u0330": "\\wideutilde{{{0}}}",
|
||||||
|
"\u0331": "\\underbar{{{0}}}",
|
||||||
|
"\u20e8": "\\threeunderdot{{{0}}}",
|
||||||
|
"\u20ec": "\\underrightharpoondown{{{0}}}",
|
||||||
|
"\u20ed": "\\underleftharpoondown{{{0}}}",
|
||||||
|
"\u20ee": "\\underledtarrow{{{0}}}",
|
||||||
|
"\u20ef": "\\underrightarrow{{{0}}}",
|
||||||
|
# Over | group
|
||||||
|
"\u23b4": "\\overbracket{{{0}}}",
|
||||||
|
"\u23dc": "\\overparen{{{0}}}",
|
||||||
|
"\u23de": "\\overbrace{{{0}}}",
|
||||||
|
# Under| group
|
||||||
|
"\u23b5": "\\underbracket{{{0}}}",
|
||||||
|
"\u23dd": "\\underparen{{{0}}}",
|
||||||
|
"\u23df": "\\underbrace{{{0}}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHR_BO = {
|
||||||
|
# Big operators,
|
||||||
|
"\u2140": "\\Bbbsum",
|
||||||
|
"\u220f": "\\prod",
|
||||||
|
"\u2210": "\\coprod",
|
||||||
|
"\u2211": "\\sum",
|
||||||
|
"\u222b": "\\int",
|
||||||
|
"\u22c0": "\\bigwedge",
|
||||||
|
"\u22c1": "\\bigvee",
|
||||||
|
"\u22c2": "\\bigcap",
|
||||||
|
"\u22c3": "\\bigcup",
|
||||||
|
"\u2a00": "\\bigodot",
|
||||||
|
"\u2a01": "\\bigoplus",
|
||||||
|
"\u2a02": "\\bigotimes",
|
||||||
|
}
|
||||||
|
|
||||||
|
T = {
|
||||||
|
"\u2192": "\\rightarrow ",
|
||||||
|
# Greek letters
|
||||||
|
"\U0001d6fc": "\\alpha ",
|
||||||
|
"\U0001d6fd": "\\beta ",
|
||||||
|
"\U0001d6fe": "\\gamma ",
|
||||||
|
"\U0001d6ff": "\\theta ",
|
||||||
|
"\U0001d700": "\\epsilon ",
|
||||||
|
"\U0001d701": "\\zeta ",
|
||||||
|
"\U0001d702": "\\eta ",
|
||||||
|
"\U0001d703": "\\theta ",
|
||||||
|
"\U0001d704": "\\iota ",
|
||||||
|
"\U0001d705": "\\kappa ",
|
||||||
|
"\U0001d706": "\\lambda ",
|
||||||
|
"\U0001d707": "\\m ",
|
||||||
|
"\U0001d708": "\\n ",
|
||||||
|
"\U0001d709": "\\xi ",
|
||||||
|
"\U0001d70a": "\\omicron ",
|
||||||
|
"\U0001d70b": "\\pi ",
|
||||||
|
"\U0001d70c": "\\rho ",
|
||||||
|
"\U0001d70d": "\\varsigma ",
|
||||||
|
"\U0001d70e": "\\sigma ",
|
||||||
|
"\U0001d70f": "\\ta ",
|
||||||
|
"\U0001d710": "\\upsilon ",
|
||||||
|
"\U0001d711": "\\phi ",
|
||||||
|
"\U0001d712": "\\chi ",
|
||||||
|
"\U0001d713": "\\psi ",
|
||||||
|
"\U0001d714": "\\omega ",
|
||||||
|
"\U0001d715": "\\partial ",
|
||||||
|
"\U0001d716": "\\varepsilon ",
|
||||||
|
"\U0001d717": "\\vartheta ",
|
||||||
|
"\U0001d718": "\\varkappa ",
|
||||||
|
"\U0001d719": "\\varphi ",
|
||||||
|
"\U0001d71a": "\\varrho ",
|
||||||
|
"\U0001d71b": "\\varpi ",
|
||||||
|
# Relation symbols
|
||||||
|
"\u2190": "\\leftarrow ",
|
||||||
|
"\u2191": "\\uparrow ",
|
||||||
|
"\u2192": "\\rightarrow ",
|
||||||
|
"\u2193": "\\downright ",
|
||||||
|
"\u2194": "\\leftrightarrow ",
|
||||||
|
"\u2195": "\\updownarrow ",
|
||||||
|
"\u2196": "\\nwarrow ",
|
||||||
|
"\u2197": "\\nearrow ",
|
||||||
|
"\u2198": "\\searrow ",
|
||||||
|
"\u2199": "\\swarrow ",
|
||||||
|
"\u22ee": "\\vdots ",
|
||||||
|
"\u22ef": "\\cdots ",
|
||||||
|
"\u22f0": "\\adots ",
|
||||||
|
"\u22f1": "\\ddots ",
|
||||||
|
"\u2260": "\\ne ",
|
||||||
|
"\u2264": "\\leq ",
|
||||||
|
"\u2265": "\\geq ",
|
||||||
|
"\u2266": "\\leqq ",
|
||||||
|
"\u2267": "\\geqq ",
|
||||||
|
"\u2268": "\\lneqq ",
|
||||||
|
"\u2269": "\\gneqq ",
|
||||||
|
"\u226a": "\\ll ",
|
||||||
|
"\u226b": "\\gg ",
|
||||||
|
"\u2208": "\\in ",
|
||||||
|
"\u2209": "\\notin ",
|
||||||
|
"\u220b": "\\ni ",
|
||||||
|
"\u220c": "\\nni ",
|
||||||
|
# Ordinary symbols
|
||||||
|
"\u221e": "\\infty ",
|
||||||
|
# Binary relations
|
||||||
|
"\u00b1": "\\pm ",
|
||||||
|
"\u2213": "\\mp ",
|
||||||
|
# Italic, Latin, uppercase
|
||||||
|
"\U0001d434": "A",
|
||||||
|
"\U0001d435": "B",
|
||||||
|
"\U0001d436": "C",
|
||||||
|
"\U0001d437": "D",
|
||||||
|
"\U0001d438": "E",
|
||||||
|
"\U0001d439": "F",
|
||||||
|
"\U0001d43a": "G",
|
||||||
|
"\U0001d43b": "H",
|
||||||
|
"\U0001d43c": "I",
|
||||||
|
"\U0001d43d": "J",
|
||||||
|
"\U0001d43e": "K",
|
||||||
|
"\U0001d43f": "L",
|
||||||
|
"\U0001d440": "M",
|
||||||
|
"\U0001d441": "N",
|
||||||
|
"\U0001d442": "O",
|
||||||
|
"\U0001d443": "P",
|
||||||
|
"\U0001d444": "Q",
|
||||||
|
"\U0001d445": "R",
|
||||||
|
"\U0001d446": "S",
|
||||||
|
"\U0001d447": "T",
|
||||||
|
"\U0001d448": "U",
|
||||||
|
"\U0001d449": "V",
|
||||||
|
"\U0001d44a": "W",
|
||||||
|
"\U0001d44b": "X",
|
||||||
|
"\U0001d44c": "Y",
|
||||||
|
"\U0001d44d": "Z",
|
||||||
|
# Italic, Latin, lowercase
|
||||||
|
"\U0001d44e": "a",
|
||||||
|
"\U0001d44f": "b",
|
||||||
|
"\U0001d450": "c",
|
||||||
|
"\U0001d451": "d",
|
||||||
|
"\U0001d452": "e",
|
||||||
|
"\U0001d453": "f",
|
||||||
|
"\U0001d454": "g",
|
||||||
|
"\U0001d456": "i",
|
||||||
|
"\U0001d457": "j",
|
||||||
|
"\U0001d458": "k",
|
||||||
|
"\U0001d459": "l",
|
||||||
|
"\U0001d45a": "m",
|
||||||
|
"\U0001d45b": "n",
|
||||||
|
"\U0001d45c": "o",
|
||||||
|
"\U0001d45d": "p",
|
||||||
|
"\U0001d45e": "q",
|
||||||
|
"\U0001d45f": "r",
|
||||||
|
"\U0001d460": "s",
|
||||||
|
"\U0001d461": "t",
|
||||||
|
"\U0001d462": "u",
|
||||||
|
"\U0001d463": "v",
|
||||||
|
"\U0001d464": "w",
|
||||||
|
"\U0001d465": "x",
|
||||||
|
"\U0001d466": "y",
|
||||||
|
"\U0001d467": "z",
|
||||||
|
}
|
||||||
|
|
||||||
|
FUNC = {
|
||||||
|
"sin": "\\sin({fe})",
|
||||||
|
"cos": "\\cos({fe})",
|
||||||
|
"tan": "\\tan({fe})",
|
||||||
|
"arcsin": "\\arcsin({fe})",
|
||||||
|
"arccos": "\\arccos({fe})",
|
||||||
|
"arctan": "\\arctan({fe})",
|
||||||
|
"arccot": "\\arccot({fe})",
|
||||||
|
"sinh": "\\sinh({fe})",
|
||||||
|
"cosh": "\\cosh({fe})",
|
||||||
|
"tanh": "\\tanh({fe})",
|
||||||
|
"coth": "\\coth({fe})",
|
||||||
|
"sec": "\\sec({fe})",
|
||||||
|
"csc": "\\csc({fe})",
|
||||||
|
}
|
||||||
|
|
||||||
|
FUNC_PLACE = "{fe}"
|
||||||
|
|
||||||
|
BRK = "\\\\"
|
||||||
|
|
||||||
|
CHR_DEFAULT = {
|
||||||
|
"ACC_VAL": "\\hat{{{0}}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
POS = {
|
||||||
|
"top": "\\overline{{{0}}}", # not sure
|
||||||
|
"bot": "\\underline{{{0}}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
POS_DEFAULT = {
|
||||||
|
"BAR_VAL": "\\overline{{{0}}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUB = "_{{{0}}}"
|
||||||
|
|
||||||
|
SUP = "^{{{0}}}"
|
||||||
|
|
||||||
|
F = {
|
||||||
|
"bar": "\\frac{{{num}}}{{{den}}}",
|
||||||
|
"skw": r"^{{{num}}}/_{{{den}}}",
|
||||||
|
"noBar": "\\genfrac{{}}{{}}{{0pt}}{{}}{{{num}}}{{{den}}}",
|
||||||
|
"lin": "{{{num}}}/{{{den}}}",
|
||||||
|
}
|
||||||
|
F_DEFAULT = "\\frac{{{num}}}{{{den}}}"
|
||||||
|
|
||||||
|
D = "\\left{left}{text}\\right{right}"
|
||||||
|
|
||||||
|
D_DEFAULT = {
|
||||||
|
"left": "(",
|
||||||
|
"right": ")",
|
||||||
|
"null": ".",
|
||||||
|
}
|
||||||
|
|
||||||
|
RAD = "\\sqrt[{deg}]{{{text}}}"
|
||||||
|
|
||||||
|
RAD_DEFAULT = "\\sqrt{{{text}}}"
|
||||||
|
|
||||||
|
ARR = "\\begin{{array}}{{c}}{text}\\end{{array}}"
|
||||||
|
|
||||||
|
LIM_FUNC = {
|
||||||
|
"lim": "\\lim_{{{lim}}}",
|
||||||
|
"max": "\\max_{{{lim}}}",
|
||||||
|
"min": "\\min_{{{lim}}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
LIM_TO = ("\\rightarrow", "\\to")
|
||||||
|
|
||||||
|
LIM_UPP = "\\overset{{{lim}}}{{{text}}}"
|
||||||
|
|
||||||
|
M = "\\begin{{matrix}}{text}\\end{{matrix}}"
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Office Math Markup Language (OMML)
|
||||||
|
Adapted from https://github.com/xiilei/dwml/blob/master/dwml/omml.py
|
||||||
|
On 25/03/2025
|
||||||
|
"""
|
||||||
|
|
||||||
|
from defusedxml import ElementTree as ET
|
||||||
|
|
||||||
|
from .latex_dict import (
|
||||||
|
CHARS,
|
||||||
|
CHR,
|
||||||
|
CHR_BO,
|
||||||
|
CHR_DEFAULT,
|
||||||
|
POS,
|
||||||
|
POS_DEFAULT,
|
||||||
|
SUB,
|
||||||
|
SUP,
|
||||||
|
F,
|
||||||
|
F_DEFAULT,
|
||||||
|
T,
|
||||||
|
FUNC,
|
||||||
|
D,
|
||||||
|
D_DEFAULT,
|
||||||
|
RAD,
|
||||||
|
RAD_DEFAULT,
|
||||||
|
ARR,
|
||||||
|
LIM_FUNC,
|
||||||
|
LIM_TO,
|
||||||
|
LIM_UPP,
|
||||||
|
M,
|
||||||
|
BRK,
|
||||||
|
BLANK,
|
||||||
|
BACKSLASH,
|
||||||
|
ALN,
|
||||||
|
FUNC_PLACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
OMML_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/math}"
|
||||||
|
|
||||||
|
|
||||||
|
def load(stream):
|
||||||
|
tree = ET.parse(stream)
|
||||||
|
for omath in tree.findall(OMML_NS + "oMath"):
|
||||||
|
yield oMath2Latex(omath)
|
||||||
|
|
||||||
|
|
||||||
|
def load_string(string):
|
||||||
|
root = ET.fromstring(string)
|
||||||
|
for omath in root.findall(OMML_NS + "oMath"):
|
||||||
|
yield oMath2Latex(omath)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_latex(strs):
|
||||||
|
last = None
|
||||||
|
new_chr = []
|
||||||
|
strs = strs.replace(r"\\", "\\")
|
||||||
|
for c in strs:
|
||||||
|
if (c in CHARS) and (last != BACKSLASH):
|
||||||
|
new_chr.append(BACKSLASH + c)
|
||||||
|
else:
|
||||||
|
new_chr.append(c)
|
||||||
|
last = c
|
||||||
|
return BLANK.join(new_chr)
|
||||||
|
|
||||||
|
|
||||||
|
def get_val(key, default=None, store=CHR):
|
||||||
|
if key is not None:
|
||||||
|
return key if not store else store.get(key, key)
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class Tag2Method(object):
|
||||||
|
def call_method(self, elm, stag=None):
|
||||||
|
getmethod = self.tag2meth.get
|
||||||
|
if stag is None:
|
||||||
|
stag = elm.tag.replace(OMML_NS, "")
|
||||||
|
method = getmethod(stag)
|
||||||
|
if method:
|
||||||
|
return method(self, elm)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def process_children_list(self, elm, include=None):
|
||||||
|
"""
|
||||||
|
process children of the elm,return iterable
|
||||||
|
"""
|
||||||
|
for _e in list(elm):
|
||||||
|
if OMML_NS not in _e.tag:
|
||||||
|
continue
|
||||||
|
stag = _e.tag.replace(OMML_NS, "")
|
||||||
|
if include and (stag not in include):
|
||||||
|
continue
|
||||||
|
t = self.call_method(_e, stag=stag)
|
||||||
|
if t is None:
|
||||||
|
t = self.process_unknow(_e, stag)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
yield (stag, t, _e)
|
||||||
|
|
||||||
|
def process_children_dict(self, elm, include=None):
|
||||||
|
"""
|
||||||
|
process children of the elm,return dict
|
||||||
|
"""
|
||||||
|
latex_chars = dict()
|
||||||
|
for stag, t, e in self.process_children_list(elm, include):
|
||||||
|
latex_chars[stag] = t
|
||||||
|
return latex_chars
|
||||||
|
|
||||||
|
def process_children(self, elm, include=None):
|
||||||
|
"""
|
||||||
|
process children of the elm,return string
|
||||||
|
"""
|
||||||
|
return BLANK.join(
|
||||||
|
(
|
||||||
|
t if not isinstance(t, Tag2Method) else str(t)
|
||||||
|
for stag, t, e in self.process_children_list(elm, include)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_unknow(self, elm, stag):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Pr(Tag2Method):
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
__val_tags = ("chr", "pos", "begChr", "endChr", "type")
|
||||||
|
|
||||||
|
__innerdict = None # can't use the __dict__
|
||||||
|
|
||||||
|
""" common properties of element"""
|
||||||
|
|
||||||
|
def __init__(self, elm):
|
||||||
|
self.__innerdict = {}
|
||||||
|
self.text = self.process_children(elm)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.__str__(self)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return self.__innerdict.get(name, None)
|
||||||
|
|
||||||
|
def do_brk(self, elm):
|
||||||
|
self.__innerdict["brk"] = BRK
|
||||||
|
return BRK
|
||||||
|
|
||||||
|
def do_common(self, elm):
|
||||||
|
stag = elm.tag.replace(OMML_NS, "")
|
||||||
|
if stag in self.__val_tags:
|
||||||
|
t = elm.get("{0}val".format(OMML_NS))
|
||||||
|
self.__innerdict[stag] = t
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag2meth = {
|
||||||
|
"brk": do_brk,
|
||||||
|
"chr": do_common,
|
||||||
|
"pos": do_common,
|
||||||
|
"begChr": do_common,
|
||||||
|
"endChr": do_common,
|
||||||
|
"type": do_common,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class oMath2Latex(Tag2Method):
|
||||||
|
"""
|
||||||
|
Convert oMath element of omml to latex
|
||||||
|
"""
|
||||||
|
|
||||||
|
_t_dict = T
|
||||||
|
|
||||||
|
__direct_tags = ("box", "sSub", "sSup", "sSubSup", "num", "den", "deg", "e")
|
||||||
|
|
||||||
|
def __init__(self, element):
|
||||||
|
self._latex = self.process_children(element)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.latex
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.__str__(self)
|
||||||
|
|
||||||
|
def process_unknow(self, elm, stag):
|
||||||
|
if stag in self.__direct_tags:
|
||||||
|
return self.process_children(elm)
|
||||||
|
elif stag[-2:] == "Pr":
|
||||||
|
return Pr(elm)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latex(self):
|
||||||
|
return self._latex
|
||||||
|
|
||||||
|
def do_acc(self, elm):
|
||||||
|
"""
|
||||||
|
the accent function
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
latex_s = get_val(
|
||||||
|
c_dict["accPr"].chr, default=CHR_DEFAULT.get("ACC_VAL"), store=CHR
|
||||||
|
)
|
||||||
|
return latex_s.format(c_dict["e"])
|
||||||
|
|
||||||
|
def do_bar(self, elm):
|
||||||
|
"""
|
||||||
|
the bar function
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
pr = c_dict["barPr"]
|
||||||
|
latex_s = get_val(pr.pos, default=POS_DEFAULT.get("BAR_VAL"), store=POS)
|
||||||
|
return pr.text + latex_s.format(c_dict["e"])
|
||||||
|
|
||||||
|
def do_d(self, elm):
|
||||||
|
"""
|
||||||
|
the delimiter object
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
pr = c_dict["dPr"]
|
||||||
|
null = D_DEFAULT.get("null")
|
||||||
|
s_val = get_val(pr.begChr, default=D_DEFAULT.get("left"), store=T)
|
||||||
|
e_val = get_val(pr.endChr, default=D_DEFAULT.get("right"), store=T)
|
||||||
|
return pr.text + D.format(
|
||||||
|
left=null if not s_val else escape_latex(s_val),
|
||||||
|
text=c_dict["e"],
|
||||||
|
right=null if not e_val else escape_latex(e_val),
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_spre(self, elm):
|
||||||
|
"""
|
||||||
|
the Pre-Sub-Superscript object -- Not support yet
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_sub(self, elm):
|
||||||
|
text = self.process_children(elm)
|
||||||
|
return SUB.format(text)
|
||||||
|
|
||||||
|
def do_sup(self, elm):
|
||||||
|
text = self.process_children(elm)
|
||||||
|
return SUP.format(text)
|
||||||
|
|
||||||
|
def do_f(self, elm):
|
||||||
|
"""
|
||||||
|
the fraction object
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
pr = c_dict["fPr"]
|
||||||
|
latex_s = get_val(pr.type, default=F_DEFAULT, store=F)
|
||||||
|
return pr.text + latex_s.format(num=c_dict.get("num"), den=c_dict.get("den"))
|
||||||
|
|
||||||
|
def do_func(self, elm):
|
||||||
|
"""
|
||||||
|
the Function-Apply object (Examples:sin cos)
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
func_name = c_dict.get("fName")
|
||||||
|
return func_name.replace(FUNC_PLACE, c_dict.get("e"))
|
||||||
|
|
||||||
|
def do_fname(self, elm):
|
||||||
|
"""
|
||||||
|
the func name
|
||||||
|
"""
|
||||||
|
latex_chars = []
|
||||||
|
for stag, t, e in self.process_children_list(elm):
|
||||||
|
if stag == "r":
|
||||||
|
if FUNC.get(t):
|
||||||
|
latex_chars.append(FUNC[t])
|
||||||
|
else:
|
||||||
|
raise NotImplementedError("Not support func %s" % t)
|
||||||
|
else:
|
||||||
|
latex_chars.append(t)
|
||||||
|
t = BLANK.join(latex_chars)
|
||||||
|
return t if FUNC_PLACE in t else t + FUNC_PLACE # do_func will replace this
|
||||||
|
|
||||||
|
def do_groupchr(self, elm):
|
||||||
|
"""
|
||||||
|
the Group-Character object
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
pr = c_dict["groupChrPr"]
|
||||||
|
latex_s = get_val(pr.chr)
|
||||||
|
return pr.text + latex_s.format(c_dict["e"])
|
||||||
|
|
||||||
|
def do_rad(self, elm):
|
||||||
|
"""
|
||||||
|
the radical object
|
||||||
|
"""
|
||||||
|
c_dict = self.process_children_dict(elm)
|
||||||
|
text = c_dict.get("e")
|
||||||
|
deg_text = c_dict.get("deg")
|
||||||
|
if deg_text:
|
||||||
|
return RAD.format(deg=deg_text, text=text)
|
||||||
|
else:
|
||||||
|
return RAD_DEFAULT.format(text=text)
|
||||||
|
|
||||||
|
def do_eqarr(self, elm):
|
||||||
|
"""
|
||||||
|
the Array object
|
||||||
|
"""
|
||||||
|
return ARR.format(
|
||||||
|
text=BRK.join(
|
||||||
|
[t for stag, t, e in self.process_children_list(elm, include=("e",))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_limlow(self, elm):
|
||||||
|
"""
|
||||||
|
the Lower-Limit object
|
||||||
|
"""
|
||||||
|
t_dict = self.process_children_dict(elm, include=("e", "lim"))
|
||||||
|
latex_s = LIM_FUNC.get(t_dict["e"])
|
||||||
|
if not latex_s:
|
||||||
|
raise NotImplementedError("Not support lim %s" % t_dict["e"])
|
||||||
|
else:
|
||||||
|
return latex_s.format(lim=t_dict.get("lim"))
|
||||||
|
|
||||||
|
def do_limupp(self, elm):
|
||||||
|
"""
|
||||||
|
the Upper-Limit object
|
||||||
|
"""
|
||||||
|
t_dict = self.process_children_dict(elm, include=("e", "lim"))
|
||||||
|
return LIM_UPP.format(lim=t_dict.get("lim"), text=t_dict.get("e"))
|
||||||
|
|
||||||
|
def do_lim(self, elm):
|
||||||
|
"""
|
||||||
|
the lower limit of the limLow object and the upper limit of the limUpp function
|
||||||
|
"""
|
||||||
|
return self.process_children(elm).replace(LIM_TO[0], LIM_TO[1])
|
||||||
|
|
||||||
|
def do_m(self, elm):
|
||||||
|
"""
|
||||||
|
the Matrix object
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for stag, t, e in self.process_children_list(elm):
|
||||||
|
if stag == "mPr":
|
||||||
|
pass
|
||||||
|
elif stag == "mr":
|
||||||
|
rows.append(t)
|
||||||
|
return M.format(text=BRK.join(rows))
|
||||||
|
|
||||||
|
def do_mr(self, elm):
|
||||||
|
"""
|
||||||
|
a single row of the matrix m
|
||||||
|
"""
|
||||||
|
return ALN.join(
|
||||||
|
[t for stag, t, e in self.process_children_list(elm, include=("e",))]
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_nary(self, elm):
|
||||||
|
"""
|
||||||
|
the n-ary object
|
||||||
|
"""
|
||||||
|
res = []
|
||||||
|
bo = ""
|
||||||
|
for stag, t, e in self.process_children_list(elm):
|
||||||
|
if stag == "naryPr":
|
||||||
|
bo = get_val(t.chr, store=CHR_BO)
|
||||||
|
else:
|
||||||
|
res.append(t)
|
||||||
|
return bo + BLANK.join(res)
|
||||||
|
|
||||||
|
def do_r(self, elm):
|
||||||
|
"""
|
||||||
|
Get text from 'r' element,And try convert them to latex symbols
|
||||||
|
@todo text style support , (sty)
|
||||||
|
@todo \text (latex pure text support)
|
||||||
|
"""
|
||||||
|
_str = []
|
||||||
|
for s in elm.findtext("./{0}t".format(OMML_NS)):
|
||||||
|
# s = s if isinstance(s,unicode) else unicode(s,'utf-8')
|
||||||
|
_str.append(self._t_dict.get(s, s))
|
||||||
|
return escape_latex(BLANK.join(_str))
|
||||||
|
|
||||||
|
tag2meth = {
|
||||||
|
"acc": do_acc,
|
||||||
|
"r": do_r,
|
||||||
|
"bar": do_bar,
|
||||||
|
"sub": do_sub,
|
||||||
|
"sup": do_sup,
|
||||||
|
"f": do_f,
|
||||||
|
"func": do_func,
|
||||||
|
"fName": do_fname,
|
||||||
|
"groupChr": do_groupchr,
|
||||||
|
"d": do_d,
|
||||||
|
"rad": do_rad,
|
||||||
|
"eqArr": do_eqarr,
|
||||||
|
"limLow": do_limlow,
|
||||||
|
"limUpp": do_limupp,
|
||||||
|
"lim": do_lim,
|
||||||
|
"m": do_m,
|
||||||
|
"mr": do_mr,
|
||||||
|
"nary": do_nary,
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import BinaryIO
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
from .math.omml import OMML_NS, oMath2Latex
|
||||||
|
|
||||||
|
MATH_ROOT_TEMPLATE = "".join(
|
||||||
|
(
|
||||||
|
"<w:document ",
|
||||||
|
'xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas" ',
|
||||||
|
'xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" ',
|
||||||
|
'xmlns:o="urn:schemas-microsoft-com:office:office" ',
|
||||||
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ',
|
||||||
|
'xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" ',
|
||||||
|
'xmlns:v="urn:schemas-microsoft-com:vml" ',
|
||||||
|
'xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing" ',
|
||||||
|
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ',
|
||||||
|
'xmlns:w10="urn:schemas-microsoft-com:office:word" ',
|
||||||
|
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ',
|
||||||
|
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ',
|
||||||
|
'xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" ',
|
||||||
|
'xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk" ',
|
||||||
|
'xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" ',
|
||||||
|
'xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" mc:Ignorable="w14 wp14">',
|
||||||
|
"{0}</w:document>",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_omath_to_latex(tag: Tag) -> str:
|
||||||
|
"""
|
||||||
|
Converts an OMML (Office Math Markup Language) tag to LaTeX format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag (Tag): A BeautifulSoup Tag object representing the OMML element.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The LaTeX representation of the OMML element.
|
||||||
|
"""
|
||||||
|
# Format the tag into a complete XML document string
|
||||||
|
math_root = ET.fromstring(MATH_ROOT_TEMPLATE.format(str(tag)))
|
||||||
|
# Find the 'oMath' element within the XML document
|
||||||
|
math_element = math_root.find(OMML_NS + "oMath")
|
||||||
|
# Convert the 'oMath' element to LaTeX using the oMath2Latex function
|
||||||
|
latex = oMath2Latex(math_element).latex
|
||||||
|
return latex
|
||||||
|
|
||||||
|
|
||||||
|
def _get_omath_tag_replacement(tag: Tag, block: bool = False) -> Tag:
|
||||||
|
"""
|
||||||
|
Creates a replacement tag for an OMML (Office Math Markup Language) element.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag (Tag): A BeautifulSoup Tag object representing the "oMath" element.
|
||||||
|
block (bool, optional): If True, the LaTeX will be wrapped in double dollar signs for block mode. Defaults to False.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tag: A BeautifulSoup Tag object representing the replacement element.
|
||||||
|
"""
|
||||||
|
t_tag = Tag(name="w:t")
|
||||||
|
t_tag.string = (
|
||||||
|
f"$${_convert_omath_to_latex(tag)}$$"
|
||||||
|
if block
|
||||||
|
else f"${_convert_omath_to_latex(tag)}$"
|
||||||
|
)
|
||||||
|
r_tag = Tag(name="w:r")
|
||||||
|
r_tag.append(t_tag)
|
||||||
|
return r_tag
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_equations(tag: Tag):
|
||||||
|
"""
|
||||||
|
Replaces OMML (Office Math Markup Language) elements with their LaTeX equivalents.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag (Tag): A BeautifulSoup Tag object representing the OMML element. Could be either "oMathPara" or "oMath".
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the tag is not supported.
|
||||||
|
"""
|
||||||
|
if tag.name == "oMathPara":
|
||||||
|
# Create a new paragraph tag
|
||||||
|
p_tag = Tag(name="w:p")
|
||||||
|
# Replace each 'oMath' child tag with its LaTeX equivalent as block equations
|
||||||
|
for child_tag in tag.find_all("oMath"):
|
||||||
|
p_tag.append(_get_omath_tag_replacement(child_tag, block=True))
|
||||||
|
# Replace the original 'oMathPara' tag with the new paragraph tag
|
||||||
|
tag.replace_with(p_tag)
|
||||||
|
elif tag.name == "oMath":
|
||||||
|
# Replace the 'oMath' tag with its LaTeX equivalent as inline equation
|
||||||
|
tag.replace_with(_get_omath_tag_replacement(tag, block=False))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Not supported tag: {tag.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _pre_process_math(content: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Pre-processes the math content in a DOCX -> XML file by converting OMML (Office Math Markup Language) elements to LaTeX.
|
||||||
|
This preprocessed content can be directly replaced in the DOCX file -> XMLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (bytes): The XML content of the DOCX file as bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: The processed content with OMML elements replaced by their LaTeX equivalents, encoded as bytes.
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(content.decode(), features="xml")
|
||||||
|
for tag in soup.find_all("oMathPara"):
|
||||||
|
_replace_equations(tag)
|
||||||
|
for tag in soup.find_all("oMath"):
|
||||||
|
_replace_equations(tag)
|
||||||
|
return str(soup).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def pre_process_docx(input_docx: BinaryIO) -> BinaryIO:
|
||||||
|
"""
|
||||||
|
Pre-processes a DOCX file with provided steps.
|
||||||
|
|
||||||
|
The process works by unzipping the DOCX file in memory, transforming specific XML files
|
||||||
|
(such as converting OMML elements to LaTeX), and then zipping everything back into a
|
||||||
|
DOCX file without writing to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_docx (BinaryIO): A binary input stream representing the DOCX file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BinaryIO: A binary output stream representing the processed DOCX file.
|
||||||
|
"""
|
||||||
|
output_docx = BytesIO()
|
||||||
|
# The files that need to be pre-processed from .docx
|
||||||
|
pre_process_enable_files = [
|
||||||
|
"word/document.xml",
|
||||||
|
"word/footnotes.xml",
|
||||||
|
"word/endnotes.xml",
|
||||||
|
]
|
||||||
|
with zipfile.ZipFile(input_docx, mode="r") as zip_input:
|
||||||
|
files = {name: zip_input.read(name) for name in zip_input.namelist()}
|
||||||
|
with zipfile.ZipFile(output_docx, mode="w") as zip_output:
|
||||||
|
zip_output.comment = zip_input.comment
|
||||||
|
for name, content in files.items():
|
||||||
|
if name in pre_process_enable_files:
|
||||||
|
try:
|
||||||
|
# Pre-process the content
|
||||||
|
updated_content = _pre_process_math(content)
|
||||||
|
# In the future, if there are more pre-processing steps, they can be added here
|
||||||
|
zip_output.writestr(name, updated_content)
|
||||||
|
except Exception:
|
||||||
|
# If there is an error in processing the content, write the original content
|
||||||
|
zip_output.writestr(name, content)
|
||||||
|
else:
|
||||||
|
zip_output.writestr(name, content)
|
||||||
|
output_docx.seek(0)
|
||||||
|
return output_docx
|
||||||
48
packages/markitdown/src/markitdown/converters/__init__.py
Normal file
48
packages/markitdown/src/markitdown/converters/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
from ._plain_text_converter import PlainTextConverter
|
||||||
|
from ._html_converter import HtmlConverter
|
||||||
|
from ._rss_converter import RssConverter
|
||||||
|
from ._wikipedia_converter import WikipediaConverter
|
||||||
|
from ._youtube_converter import YouTubeConverter
|
||||||
|
from ._ipynb_converter import IpynbConverter
|
||||||
|
from ._bing_serp_converter import BingSerpConverter
|
||||||
|
from ._pdf_converter import PdfConverter
|
||||||
|
from ._docx_converter import DocxConverter
|
||||||
|
from ._xlsx_converter import XlsxConverter, XlsConverter
|
||||||
|
from ._pptx_converter import PptxConverter
|
||||||
|
from ._image_converter import ImageConverter
|
||||||
|
from ._audio_converter import AudioConverter
|
||||||
|
from ._outlook_msg_converter import OutlookMsgConverter
|
||||||
|
from ._zip_converter import ZipConverter
|
||||||
|
from ._doc_intel_converter import (
|
||||||
|
DocumentIntelligenceConverter,
|
||||||
|
DocumentIntelligenceFileType,
|
||||||
|
)
|
||||||
|
from ._epub_converter import EpubConverter
|
||||||
|
from ._csv_converter import CsvConverter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlainTextConverter",
|
||||||
|
"HtmlConverter",
|
||||||
|
"RssConverter",
|
||||||
|
"WikipediaConverter",
|
||||||
|
"YouTubeConverter",
|
||||||
|
"IpynbConverter",
|
||||||
|
"BingSerpConverter",
|
||||||
|
"PdfConverter",
|
||||||
|
"DocxConverter",
|
||||||
|
"XlsxConverter",
|
||||||
|
"XlsConverter",
|
||||||
|
"PptxConverter",
|
||||||
|
"ImageConverter",
|
||||||
|
"AudioConverter",
|
||||||
|
"OutlookMsgConverter",
|
||||||
|
"ZipConverter",
|
||||||
|
"DocumentIntelligenceConverter",
|
||||||
|
"DocumentIntelligenceFileType",
|
||||||
|
"EpubConverter",
|
||||||
|
"CsvConverter",
|
||||||
|
]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from typing import Any, BinaryIO
|
||||||
|
|
||||||
|
from ._exiftool import exiftool_metadata
|
||||||
|
from ._transcribe_audio import transcribe_audio
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import MissingDependencyException
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"audio/x-wav",
|
||||||
|
"audio/mpeg",
|
||||||
|
"video/mp4",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".wav",
|
||||||
|
".mp3",
|
||||||
|
".m4a",
|
||||||
|
".mp4",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AudioConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts audio files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
md_content = ""
|
||||||
|
|
||||||
|
# Add metadata
|
||||||
|
metadata = exiftool_metadata(
|
||||||
|
file_stream, exiftool_path=kwargs.get("exiftool_path")
|
||||||
|
)
|
||||||
|
if metadata:
|
||||||
|
for f in [
|
||||||
|
"Title",
|
||||||
|
"Artist",
|
||||||
|
"Author",
|
||||||
|
"Band",
|
||||||
|
"Album",
|
||||||
|
"Genre",
|
||||||
|
"Track",
|
||||||
|
"DateTimeOriginal",
|
||||||
|
"CreateDate",
|
||||||
|
# "Duration", -- Wrong values when read from memory
|
||||||
|
"NumChannels",
|
||||||
|
"SampleRate",
|
||||||
|
"AvgBytesPerSec",
|
||||||
|
"BitsPerSample",
|
||||||
|
]:
|
||||||
|
if f in metadata:
|
||||||
|
md_content += f"{f}: {metadata[f]}\n"
|
||||||
|
|
||||||
|
# Figure out the audio format for transcription
|
||||||
|
if stream_info.extension == ".wav" or stream_info.mimetype == "audio/x-wav":
|
||||||
|
audio_format = "wav"
|
||||||
|
elif stream_info.extension == ".mp3" or stream_info.mimetype == "audio/mpeg":
|
||||||
|
audio_format = "mp3"
|
||||||
|
elif (
|
||||||
|
stream_info.extension in [".mp4", ".m4a"]
|
||||||
|
or stream_info.mimetype == "video/mp4"
|
||||||
|
):
|
||||||
|
audio_format = "mp4"
|
||||||
|
else:
|
||||||
|
audio_format = None
|
||||||
|
|
||||||
|
# Transcribe
|
||||||
|
if audio_format:
|
||||||
|
try:
|
||||||
|
transcript = transcribe_audio(file_stream, audio_format=audio_format)
|
||||||
|
if transcript:
|
||||||
|
md_content += "\n\n### Audio Transcript:\n" + transcript
|
||||||
|
except MissingDependencyException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Return the result
|
||||||
|
return DocumentConverterResult(markdown=md_content.strip())
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
from typing import Any, BinaryIO
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from ._markdownify import _CustomMarkdownify
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BingSerpConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Handle Bing results pages (only the organic search results).
|
||||||
|
NOTE: It is better to use the Bing API
|
||||||
|
"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Make sure we're dealing with HTML content *from* Bing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = stream_info.url or ""
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if not re.search(r"^https://www\.bing\.com/search\?q=", url):
|
||||||
|
# Not a Bing SERP URL
|
||||||
|
return False
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Not HTML content
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
assert stream_info.url is not None
|
||||||
|
|
||||||
|
# Parse the query parameters
|
||||||
|
parsed_params = parse_qs(urlparse(stream_info.url).query)
|
||||||
|
query = parsed_params.get("q", [""])[0]
|
||||||
|
|
||||||
|
# Parse the stream
|
||||||
|
encoding = "utf-8" if stream_info.charset is None else stream_info.charset
|
||||||
|
soup = BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)
|
||||||
|
|
||||||
|
# Clean up some formatting
|
||||||
|
for tptt in soup.find_all(class_="tptt"):
|
||||||
|
if hasattr(tptt, "string") and tptt.string:
|
||||||
|
tptt.string += " "
|
||||||
|
for slug in soup.find_all(class_="algoSlug_icon"):
|
||||||
|
slug.extract()
|
||||||
|
|
||||||
|
# Parse the algorithmic results
|
||||||
|
_markdownify = _CustomMarkdownify(**kwargs)
|
||||||
|
results = list()
|
||||||
|
for result in soup.find_all(class_="b_algo"):
|
||||||
|
if not hasattr(result, "find_all"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Rewrite redirect urls
|
||||||
|
for a in result.find_all("a", href=True):
|
||||||
|
parsed_href = urlparse(a["href"])
|
||||||
|
qs = parse_qs(parsed_href.query)
|
||||||
|
|
||||||
|
# The destination is contained in the u parameter,
|
||||||
|
# but appears to be base64 encoded, with some prefix
|
||||||
|
if "u" in qs:
|
||||||
|
u = (
|
||||||
|
qs["u"][0][2:].strip() + "=="
|
||||||
|
) # Python 3 doesn't care about extra padding
|
||||||
|
|
||||||
|
try:
|
||||||
|
# RFC 4648 / Base64URL" variant, which uses "-" and "_"
|
||||||
|
a["href"] = base64.b64decode(u, altchars="-_").decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
except binascii.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Convert to markdown
|
||||||
|
md_result = _markdownify.convert_soup(result).strip()
|
||||||
|
lines = [line.strip() for line in re.split(r"\n+", md_result)]
|
||||||
|
results.append("\n".join([line for line in lines if len(line) > 0]))
|
||||||
|
|
||||||
|
webpage_text = (
|
||||||
|
f"## A Bing search for '{query}' found the following results:\n\n"
|
||||||
|
+ "\n\n".join(results)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=webpage_text,
|
||||||
|
title=None if soup.title is None else soup.title.string,
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from charset_normalizer import from_bytes
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/csv",
|
||||||
|
"application/csv",
|
||||||
|
]
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".csv"]
|
||||||
|
|
||||||
|
|
||||||
|
class CsvConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts CSV files to Markdown tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Read the file content
|
||||||
|
if stream_info.charset:
|
||||||
|
content = file_stream.read().decode(stream_info.charset)
|
||||||
|
else:
|
||||||
|
content = str(from_bytes(file_stream.read()).best())
|
||||||
|
|
||||||
|
# Parse CSV content
|
||||||
|
reader = csv.reader(io.StringIO(content))
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return DocumentConverterResult(markdown="")
|
||||||
|
|
||||||
|
# Create markdown table
|
||||||
|
markdown_table = []
|
||||||
|
|
||||||
|
# Add header row
|
||||||
|
markdown_table.append("| " + " | ".join(rows[0]) + " |")
|
||||||
|
|
||||||
|
# Add separator row
|
||||||
|
markdown_table.append("| " + " | ".join(["---"] * len(rows[0])) + " |")
|
||||||
|
|
||||||
|
# Add data rows
|
||||||
|
for row in rows[1:]:
|
||||||
|
# Make sure row has the same number of columns as header
|
||||||
|
while len(row) < len(rows[0]):
|
||||||
|
row.append("")
|
||||||
|
# Truncate if row has more columns than header
|
||||||
|
row = row[: len(rows[0])]
|
||||||
|
markdown_table.append("| " + " | ".join(row) + " |")
|
||||||
|
|
||||||
|
result = "\n".join(markdown_table)
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=result)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from typing import BinaryIO, Any, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import MissingDependencyException
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
||||||
|
from azure.ai.documentintelligence.models import (
|
||||||
|
AnalyzeDocumentRequest,
|
||||||
|
AnalyzeResult,
|
||||||
|
DocumentAnalysisFeature,
|
||||||
|
)
|
||||||
|
from azure.core.credentials import AzureKeyCredential, TokenCredential
|
||||||
|
from azure.identity import DefaultAzureCredential
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
# Define these types for type hinting when the package is not available
|
||||||
|
class AzureKeyCredential:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TokenCredential:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DocumentIntelligenceClient:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class AnalyzeDocumentRequest:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class AnalyzeResult:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DocumentAnalysisFeature:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DefaultAzureCredential:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: currently, there is a bug in the document intelligence SDK with importing the "ContentFormat" enum.
|
||||||
|
# This constant is a temporary fix until the bug is resolved.
|
||||||
|
CONTENT_FORMAT = "markdown"
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentIntelligenceFileType(str, Enum):
|
||||||
|
"""Enum of file types supported by the Document Intelligence Converter."""
|
||||||
|
|
||||||
|
# No OCR
|
||||||
|
DOCX = "docx"
|
||||||
|
PPTX = "pptx"
|
||||||
|
XLSX = "xlsx"
|
||||||
|
HTML = "html"
|
||||||
|
# OCR
|
||||||
|
PDF = "pdf"
|
||||||
|
JPEG = "jpeg"
|
||||||
|
PNG = "png"
|
||||||
|
BMP = "bmp"
|
||||||
|
TIFF = "tiff"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mime_type_prefixes(types: List[DocumentIntelligenceFileType]) -> List[str]:
|
||||||
|
"""Get the MIME type prefixes for the given file types."""
|
||||||
|
prefixes: List[str] = []
|
||||||
|
for type_ in types:
|
||||||
|
if type_ == DocumentIntelligenceFileType.DOCX:
|
||||||
|
prefixes.append(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
)
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PPTX:
|
||||||
|
prefixes.append(
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml"
|
||||||
|
)
|
||||||
|
elif type_ == DocumentIntelligenceFileType.XLSX:
|
||||||
|
prefixes.append(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
elif type_ == DocumentIntelligenceFileType.HTML:
|
||||||
|
prefixes.append("text/html")
|
||||||
|
prefixes.append("application/xhtml+xml")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PDF:
|
||||||
|
prefixes.append("application/pdf")
|
||||||
|
prefixes.append("application/x-pdf")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.JPEG:
|
||||||
|
prefixes.append("image/jpeg")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PNG:
|
||||||
|
prefixes.append("image/png")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.BMP:
|
||||||
|
prefixes.append("image/bmp")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.TIFF:
|
||||||
|
prefixes.append("image/tiff")
|
||||||
|
return prefixes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_extensions(types: List[DocumentIntelligenceFileType]) -> List[str]:
|
||||||
|
"""Get the file extensions for the given file types."""
|
||||||
|
extensions: List[str] = []
|
||||||
|
for type_ in types:
|
||||||
|
if type_ == DocumentIntelligenceFileType.DOCX:
|
||||||
|
extensions.append(".docx")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PPTX:
|
||||||
|
extensions.append(".pptx")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.XLSX:
|
||||||
|
extensions.append(".xlsx")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PDF:
|
||||||
|
extensions.append(".pdf")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.JPEG:
|
||||||
|
extensions.append(".jpg")
|
||||||
|
extensions.append(".jpeg")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.PNG:
|
||||||
|
extensions.append(".png")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.BMP:
|
||||||
|
extensions.append(".bmp")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.TIFF:
|
||||||
|
extensions.append(".tiff")
|
||||||
|
elif type_ == DocumentIntelligenceFileType.HTML:
|
||||||
|
extensions.append(".html")
|
||||||
|
return extensions
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentIntelligenceConverter(DocumentConverter):
|
||||||
|
"""Specialized DocumentConverter that uses Document Intelligence to extract text from documents."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint: str,
|
||||||
|
api_version: str = "2024-07-31-preview",
|
||||||
|
credential: AzureKeyCredential | TokenCredential | None = None,
|
||||||
|
file_types: List[DocumentIntelligenceFileType] = [
|
||||||
|
DocumentIntelligenceFileType.DOCX,
|
||||||
|
DocumentIntelligenceFileType.PPTX,
|
||||||
|
DocumentIntelligenceFileType.XLSX,
|
||||||
|
DocumentIntelligenceFileType.PDF,
|
||||||
|
DocumentIntelligenceFileType.JPEG,
|
||||||
|
DocumentIntelligenceFileType.PNG,
|
||||||
|
DocumentIntelligenceFileType.BMP,
|
||||||
|
DocumentIntelligenceFileType.TIFF,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the DocumentIntelligenceConverter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint (str): The endpoint for the Document Intelligence service.
|
||||||
|
api_version (str): The API version to use. Defaults to "2024-07-31-preview".
|
||||||
|
credential (AzureKeyCredential | TokenCredential | None): The credential to use for authentication.
|
||||||
|
file_types (List[DocumentIntelligenceFileType]): The file types to accept. Defaults to all supported file types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__()
|
||||||
|
self._file_types = file_types
|
||||||
|
|
||||||
|
# Raise an error if the dependencies are not available.
|
||||||
|
# This is different than other converters since this one isn't even instantiated
|
||||||
|
# unless explicitly requested.
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
"DocumentIntelligenceConverter requires the optional dependency [az-doc-intel] (or [all]) to be installed. E.g., `pip install markitdown[az-doc-intel]`"
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
if credential is None:
|
||||||
|
if os.environ.get("AZURE_API_KEY") is None:
|
||||||
|
credential = DefaultAzureCredential()
|
||||||
|
else:
|
||||||
|
credential = AzureKeyCredential(os.environ["AZURE_API_KEY"])
|
||||||
|
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.api_version = api_version
|
||||||
|
self.doc_intel_client = DocumentIntelligenceClient(
|
||||||
|
endpoint=self.endpoint,
|
||||||
|
api_version=self.api_version,
|
||||||
|
credential=credential,
|
||||||
|
)
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in _get_file_extensions(self._file_types):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in _get_mime_type_prefixes(self._file_types):
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _analysis_features(self, stream_info: StreamInfo) -> List[str]:
|
||||||
|
"""
|
||||||
|
Helper needed to determine which analysis features to use.
|
||||||
|
Certain document analysis features are not availiable for
|
||||||
|
office filetypes (.xlsx, .pptx, .html, .docx)
|
||||||
|
"""
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
# Types that don't support ocr
|
||||||
|
no_ocr_types = [
|
||||||
|
DocumentIntelligenceFileType.DOCX,
|
||||||
|
DocumentIntelligenceFileType.PPTX,
|
||||||
|
DocumentIntelligenceFileType.XLSX,
|
||||||
|
DocumentIntelligenceFileType.HTML,
|
||||||
|
]
|
||||||
|
|
||||||
|
if extension in _get_file_extensions(no_ocr_types):
|
||||||
|
return []
|
||||||
|
|
||||||
|
for prefix in _get_mime_type_prefixes(no_ocr_types):
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
DocumentAnalysisFeature.FORMULAS, # enable formula extraction
|
||||||
|
DocumentAnalysisFeature.OCR_HIGH_RESOLUTION, # enable high resolution OCR
|
||||||
|
DocumentAnalysisFeature.STYLE_FONT, # enable font style extraction
|
||||||
|
]
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Extract the text using Azure Document Intelligence
|
||||||
|
poller = self.doc_intel_client.begin_analyze_document(
|
||||||
|
model_id="prebuilt-layout",
|
||||||
|
body=AnalyzeDocumentRequest(bytes_source=file_stream.read()),
|
||||||
|
features=self._analysis_features(stream_info),
|
||||||
|
output_content_format=CONTENT_FORMAT, # TODO: replace with "ContentFormat.MARKDOWN" when the bug is fixed
|
||||||
|
)
|
||||||
|
result: AnalyzeResult = poller.result()
|
||||||
|
|
||||||
|
# remove comments from the markdown content generated by Doc Intelligence and append to markdown string
|
||||||
|
markdown_text = re.sub(r"<!--.*?-->", "", result.content, flags=re.DOTALL)
|
||||||
|
return DocumentConverterResult(markdown=markdown_text)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import sys
|
||||||
|
import io
|
||||||
|
from warnings import warn
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
|
||||||
|
from ._html_converter import HtmlConverter
|
||||||
|
from ..converter_utils.docx.pre_process import pre_process_docx
|
||||||
|
from .._base_converter import DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import mammoth
|
||||||
|
import mammoth.docx.files
|
||||||
|
|
||||||
|
def mammoth_files_open(self, uri):
|
||||||
|
warn("DOCX: processing of r:link resources (e.g., linked images) is disabled.")
|
||||||
|
return io.BytesIO(b"")
|
||||||
|
|
||||||
|
mammoth.docx.files.Files.open = mammoth_files_open
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".docx"]
|
||||||
|
|
||||||
|
|
||||||
|
class DocxConverter(HtmlConverter):
|
||||||
|
"""
|
||||||
|
Converts DOCX files to Markdown. Style information (e.g.m headings) and tables are preserved where possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._html_converter = HtmlConverter()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Check: the dependencies
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".docx",
|
||||||
|
feature="docx",
|
||||||
|
)
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
style_map = kwargs.get("style_map", None)
|
||||||
|
pre_process_stream = pre_process_docx(file_stream)
|
||||||
|
return self._html_converter.convert_string(
|
||||||
|
mammoth.convert_to_html(pre_process_stream, style_map=style_map).value,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
146
packages/markitdown/src/markitdown/converters/_epub_converter.py
Normal file
146
packages/markitdown/src/markitdown/converters/_epub_converter.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from defusedxml import minidom
|
||||||
|
from xml.dom.minidom import Document
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any, Dict, List
|
||||||
|
|
||||||
|
from ._html_converter import HtmlConverter
|
||||||
|
from .._base_converter import DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/epub",
|
||||||
|
"application/epub+zip",
|
||||||
|
"application/x-epub+zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".epub"]
|
||||||
|
|
||||||
|
MIME_TYPE_MAPPING = {
|
||||||
|
".html": "text/html",
|
||||||
|
".xhtml": "application/xhtml+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EpubConverter(HtmlConverter):
|
||||||
|
"""
|
||||||
|
Converts EPUB files to Markdown. Style information (e.g.m headings) and tables are preserved where possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._html_converter = HtmlConverter()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
with zipfile.ZipFile(file_stream, "r") as z:
|
||||||
|
# Extracts metadata (title, authors, language, publisher, date, description, cover) from an EPUB file."""
|
||||||
|
|
||||||
|
# Locate content.opf
|
||||||
|
container_dom = minidom.parse(z.open("META-INF/container.xml"))
|
||||||
|
opf_path = container_dom.getElementsByTagName("rootfile")[0].getAttribute(
|
||||||
|
"full-path"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse content.opf
|
||||||
|
opf_dom = minidom.parse(z.open(opf_path))
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"title": self._get_text_from_node(opf_dom, "dc:title"),
|
||||||
|
"authors": self._get_all_texts_from_nodes(opf_dom, "dc:creator"),
|
||||||
|
"language": self._get_text_from_node(opf_dom, "dc:language"),
|
||||||
|
"publisher": self._get_text_from_node(opf_dom, "dc:publisher"),
|
||||||
|
"date": self._get_text_from_node(opf_dom, "dc:date"),
|
||||||
|
"description": self._get_text_from_node(opf_dom, "dc:description"),
|
||||||
|
"identifier": self._get_text_from_node(opf_dom, "dc:identifier"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract manifest items (ID → href mapping)
|
||||||
|
manifest = {
|
||||||
|
item.getAttribute("id"): item.getAttribute("href")
|
||||||
|
for item in opf_dom.getElementsByTagName("item")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract spine order (ID refs)
|
||||||
|
spine_items = opf_dom.getElementsByTagName("itemref")
|
||||||
|
spine_order = [item.getAttribute("idref") for item in spine_items]
|
||||||
|
|
||||||
|
# Convert spine order to actual file paths
|
||||||
|
base_path = "/".join(
|
||||||
|
opf_path.split("/")[:-1]
|
||||||
|
) # Get base directory of content.opf
|
||||||
|
spine = [
|
||||||
|
f"{base_path}/{manifest[item_id]}" if base_path else manifest[item_id]
|
||||||
|
for item_id in spine_order
|
||||||
|
if item_id in manifest
|
||||||
|
]
|
||||||
|
|
||||||
|
# Extract and convert the content
|
||||||
|
markdown_content: List[str] = []
|
||||||
|
for file in spine:
|
||||||
|
if file in z.namelist():
|
||||||
|
with z.open(file) as f:
|
||||||
|
filename = os.path.basename(file)
|
||||||
|
extension = os.path.splitext(filename)[1].lower()
|
||||||
|
mimetype = MIME_TYPE_MAPPING.get(extension)
|
||||||
|
converted_content = self._html_converter.convert(
|
||||||
|
f,
|
||||||
|
StreamInfo(
|
||||||
|
mimetype=mimetype,
|
||||||
|
extension=extension,
|
||||||
|
filename=filename,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
markdown_content.append(converted_content.markdown.strip())
|
||||||
|
|
||||||
|
# Format and add the metadata
|
||||||
|
metadata_markdown = []
|
||||||
|
for key, value in metadata.items():
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(value)
|
||||||
|
if value:
|
||||||
|
metadata_markdown.append(f"**{key.capitalize()}:** {value}")
|
||||||
|
|
||||||
|
markdown_content.insert(0, "\n".join(metadata_markdown))
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown="\n\n".join(markdown_content), title=metadata["title"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_text_from_node(self, dom: Document, tag_name: str) -> str | None:
|
||||||
|
"""Convenience function to extract a single occurrence of a tag (e.g., title)."""
|
||||||
|
texts = self._get_all_texts_from_nodes(dom, tag_name)
|
||||||
|
if len(texts) > 0:
|
||||||
|
return texts[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_all_texts_from_nodes(self, dom: Document, tag_name: str) -> List[str]:
|
||||||
|
"""Helper function to extract all occurrences of a tag (e.g., multiple authors)."""
|
||||||
|
texts: List[str] = []
|
||||||
|
for node in dom.getElementsByTagName(tag_name):
|
||||||
|
if node.firstChild and hasattr(node.firstChild, "nodeValue"):
|
||||||
|
texts.append(node.firstChild.nodeValue.strip())
|
||||||
|
return texts
|
||||||
52
packages/markitdown/src/markitdown/converters/_exiftool.py
Normal file
52
packages/markitdown/src/markitdown/converters/_exiftool.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import json
|
||||||
|
import locale
|
||||||
|
import subprocess
|
||||||
|
from typing import Any, BinaryIO, Union
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(version: str) -> tuple:
|
||||||
|
return tuple(map(int, (version.split("."))))
|
||||||
|
|
||||||
|
|
||||||
|
def exiftool_metadata(
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
exiftool_path: Union[str, None],
|
||||||
|
) -> Any: # Need a better type for json data
|
||||||
|
# Nothing to do
|
||||||
|
if not exiftool_path:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Verify exiftool version
|
||||||
|
try:
|
||||||
|
version_output = subprocess.run(
|
||||||
|
[exiftool_path, "-ver"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
).stdout.strip()
|
||||||
|
version = _parse_version(version_output)
|
||||||
|
min_version = (12, 24)
|
||||||
|
if version < min_version:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ExifTool version {version_output} is vulnerable to CVE-2021-22204. "
|
||||||
|
"Please upgrade to version 12.24 or later."
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, ValueError) as e:
|
||||||
|
raise RuntimeError("Failed to verify ExifTool version.") from e
|
||||||
|
|
||||||
|
# Run exiftool
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
output = subprocess.run(
|
||||||
|
[exiftool_path, "-json", "-"],
|
||||||
|
input=file_stream.read(),
|
||||||
|
capture_output=True,
|
||||||
|
text=False,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
return json.loads(
|
||||||
|
output.decode(locale.getpreferredencoding(False)),
|
||||||
|
)[0]
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import io
|
||||||
|
from typing import Any, BinaryIO, Optional
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from ._markdownify import _CustomMarkdownify
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class HtmlConverter(DocumentConverter):
|
||||||
|
"""Anything with content type text/html"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Parse the stream
|
||||||
|
encoding = "utf-8" if stream_info.charset is None else stream_info.charset
|
||||||
|
soup = BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)
|
||||||
|
|
||||||
|
# Remove javascript and style blocks
|
||||||
|
for script in soup(["script", "style"]):
|
||||||
|
script.extract()
|
||||||
|
|
||||||
|
# Print only the main content
|
||||||
|
body_elm = soup.find("body")
|
||||||
|
webpage_text = ""
|
||||||
|
if body_elm:
|
||||||
|
webpage_text = _CustomMarkdownify(**kwargs).convert_soup(body_elm)
|
||||||
|
else:
|
||||||
|
webpage_text = _CustomMarkdownify(**kwargs).convert_soup(soup)
|
||||||
|
|
||||||
|
assert isinstance(webpage_text, str)
|
||||||
|
|
||||||
|
# remove leading and trailing \n
|
||||||
|
webpage_text = webpage_text.strip()
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=webpage_text,
|
||||||
|
title=None if soup.title is None else soup.title.string,
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_string(
|
||||||
|
self, html_content: str, *, url: Optional[str] = None, **kwargs
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
"""
|
||||||
|
Non-standard convenience method to convert a string to markdown.
|
||||||
|
Given that many converters produce HTML as intermediate output, this
|
||||||
|
allows for easy conversion of HTML to markdown.
|
||||||
|
"""
|
||||||
|
return self.convert(
|
||||||
|
file_stream=io.BytesIO(html_content.encode("utf-8")),
|
||||||
|
stream_info=StreamInfo(
|
||||||
|
mimetype="text/html",
|
||||||
|
extension=".html",
|
||||||
|
charset="utf-8",
|
||||||
|
url=url,
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from typing import BinaryIO, Any, Union
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
from ._exiftool import exiftool_metadata
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".jpg", ".jpeg", ".png"]
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts images to markdown via extraction of metadata (if `exiftool` is installed), and description via a multimodal LLM (if an llm_client is configured).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
md_content = ""
|
||||||
|
|
||||||
|
# Add metadata
|
||||||
|
metadata = exiftool_metadata(
|
||||||
|
file_stream, exiftool_path=kwargs.get("exiftool_path")
|
||||||
|
)
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
for f in [
|
||||||
|
"ImageSize",
|
||||||
|
"Title",
|
||||||
|
"Caption",
|
||||||
|
"Description",
|
||||||
|
"Keywords",
|
||||||
|
"Artist",
|
||||||
|
"Author",
|
||||||
|
"DateTimeOriginal",
|
||||||
|
"CreateDate",
|
||||||
|
"GPSPosition",
|
||||||
|
]:
|
||||||
|
if f in metadata:
|
||||||
|
md_content += f"{f}: {metadata[f]}\n"
|
||||||
|
|
||||||
|
# Try describing the image with GPT
|
||||||
|
llm_client = kwargs.get("llm_client")
|
||||||
|
llm_model = kwargs.get("llm_model")
|
||||||
|
if llm_client is not None and llm_model is not None:
|
||||||
|
llm_description = self._get_llm_description(
|
||||||
|
file_stream,
|
||||||
|
stream_info,
|
||||||
|
client=llm_client,
|
||||||
|
model=llm_model,
|
||||||
|
prompt=kwargs.get("llm_prompt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if llm_description is not None:
|
||||||
|
md_content += "\n# Description:\n" + llm_description.strip() + "\n"
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=md_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_llm_description(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
*,
|
||||||
|
client,
|
||||||
|
model,
|
||||||
|
prompt=None,
|
||||||
|
) -> Union[None, str]:
|
||||||
|
if prompt is None or prompt.strip() == "":
|
||||||
|
prompt = "Write a detailed caption for this image."
|
||||||
|
|
||||||
|
# Get the content type
|
||||||
|
content_type = stream_info.mimetype
|
||||||
|
if not content_type:
|
||||||
|
content_type, _ = mimetypes.guess_type(
|
||||||
|
"_dummy" + (stream_info.extension or "")
|
||||||
|
)
|
||||||
|
if not content_type:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
base64_image = base64.b64encode(file_stream.read()).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
# Prepare the data-uri
|
||||||
|
data_uri = f"data:{content_type};base64,{base64_image}"
|
||||||
|
|
||||||
|
# Prepare the OpenAI API request
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": data_uri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Call the OpenAI API
|
||||||
|
response = client.chat.completions.create(model=model, messages=messages)
|
||||||
|
return response.choices[0].message.content
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from typing import BinaryIO, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._exceptions import FileConversionException
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
CANDIDATE_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/json",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".ipynb"]
|
||||||
|
|
||||||
|
|
||||||
|
class IpynbConverter(DocumentConverter):
|
||||||
|
"""Converts Jupyter Notebook (.ipynb) files to Markdown."""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in CANDIDATE_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
# Read further to see if it's a notebook
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
encoding = stream_info.charset or "utf-8"
|
||||||
|
notebook_content = file_stream.read().decode(encoding)
|
||||||
|
return (
|
||||||
|
"nbformat" in notebook_content
|
||||||
|
and "nbformat_minor" in notebook_content
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Parse and convert the notebook
|
||||||
|
encoding = stream_info.charset or "utf-8"
|
||||||
|
notebook_content = file_stream.read().decode(encoding=encoding)
|
||||||
|
return self._convert(json.loads(notebook_content))
|
||||||
|
|
||||||
|
def _convert(self, notebook_content: dict) -> DocumentConverterResult:
|
||||||
|
"""Helper function that converts notebook JSON content to Markdown."""
|
||||||
|
try:
|
||||||
|
md_output = []
|
||||||
|
title = None
|
||||||
|
|
||||||
|
for cell in notebook_content.get("cells", []):
|
||||||
|
cell_type = cell.get("cell_type", "")
|
||||||
|
source_lines = cell.get("source", [])
|
||||||
|
|
||||||
|
if cell_type == "markdown":
|
||||||
|
md_output.append("".join(source_lines))
|
||||||
|
|
||||||
|
# Extract the first # heading as title if not already found
|
||||||
|
if title is None:
|
||||||
|
for line in source_lines:
|
||||||
|
if line.startswith("# "):
|
||||||
|
title = line.lstrip("# ").strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
elif cell_type == "code":
|
||||||
|
# Code cells are wrapped in Markdown code blocks
|
||||||
|
md_output.append(f"```python\n{''.join(source_lines)}\n```")
|
||||||
|
elif cell_type == "raw":
|
||||||
|
md_output.append(f"```\n{''.join(source_lines)}\n```")
|
||||||
|
|
||||||
|
md_text = "\n\n".join(md_output)
|
||||||
|
|
||||||
|
# Check for title in notebook metadata
|
||||||
|
title = notebook_content.get("metadata", {}).get("title", title)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=md_text,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise FileConversionException(
|
||||||
|
f"Error converting .ipynb file: {str(e)}"
|
||||||
|
) from e
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from typing import BinaryIO, Union
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
|
||||||
|
def llm_caption(
|
||||||
|
file_stream: BinaryIO, stream_info: StreamInfo, *, client, model, prompt=None
|
||||||
|
) -> Union[None, str]:
|
||||||
|
if prompt is None or prompt.strip() == "":
|
||||||
|
prompt = "Write a detailed caption for this image."
|
||||||
|
|
||||||
|
# Get the content type
|
||||||
|
content_type = stream_info.mimetype
|
||||||
|
if not content_type:
|
||||||
|
content_type, _ = mimetypes.guess_type("_dummy" + (stream_info.extension or ""))
|
||||||
|
if not content_type:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
base64_image = base64.b64encode(file_stream.read()).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
# Prepare the data-uri
|
||||||
|
data_uri = f"data:{content_type};base64,{base64_image}"
|
||||||
|
|
||||||
|
# Prepare the OpenAI API request
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": data_uri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Call the OpenAI API
|
||||||
|
response = client.chat.completions.create(model=model, messages=messages)
|
||||||
|
return response.choices[0].message.content
|
||||||
126
packages/markitdown/src/markitdown/converters/_markdownify.py
Normal file
126
packages/markitdown/src/markitdown/converters/_markdownify.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import re
|
||||||
|
import markdownify
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
|
class _CustomMarkdownify(markdownify.MarkdownConverter):
|
||||||
|
"""
|
||||||
|
A custom version of markdownify's MarkdownConverter. Changes include:
|
||||||
|
|
||||||
|
- Altering the default heading style to use '#', '##', etc.
|
||||||
|
- Removing javascript hyperlinks.
|
||||||
|
- Truncating images with large data:uri sources.
|
||||||
|
- Ensuring URIs are properly escaped, and do not conflict with Markdown syntax
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **options: Any):
|
||||||
|
options["heading_style"] = options.get("heading_style", markdownify.ATX)
|
||||||
|
options["keep_data_uris"] = options.get("keep_data_uris", False)
|
||||||
|
# Explicitly cast options to the expected type if necessary
|
||||||
|
super().__init__(**options)
|
||||||
|
|
||||||
|
def convert_hn(
|
||||||
|
self,
|
||||||
|
n: int,
|
||||||
|
el: Any,
|
||||||
|
text: str,
|
||||||
|
convert_as_inline: Optional[bool] = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
"""Same as usual, but be sure to start with a new line"""
|
||||||
|
if not convert_as_inline:
|
||||||
|
if not re.search(r"^\n", text):
|
||||||
|
return "\n" + super().convert_hn(n, el, text, convert_as_inline) # type: ignore
|
||||||
|
|
||||||
|
return super().convert_hn(n, el, text, convert_as_inline) # type: ignore
|
||||||
|
|
||||||
|
def convert_a(
|
||||||
|
self,
|
||||||
|
el: Any,
|
||||||
|
text: str,
|
||||||
|
convert_as_inline: Optional[bool] = False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Same as usual converter, but removes Javascript links and escapes URIs."""
|
||||||
|
prefix, suffix, text = markdownify.chomp(text) # type: ignore
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if el.find_parent("pre") is not None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
href = el.get("href")
|
||||||
|
title = el.get("title")
|
||||||
|
|
||||||
|
# Escape URIs and skip non-http or file schemes
|
||||||
|
if href:
|
||||||
|
try:
|
||||||
|
parsed_url = urlparse(href) # type: ignore
|
||||||
|
if parsed_url.scheme and parsed_url.scheme.lower() not in ["http", "https", "file"]: # type: ignore
|
||||||
|
return "%s%s%s" % (prefix, text, suffix)
|
||||||
|
href = urlunparse(parsed_url._replace(path=quote(unquote(parsed_url.path)))) # type: ignore
|
||||||
|
except ValueError: # It's not clear if this ever gets thrown
|
||||||
|
return "%s%s%s" % (prefix, text, suffix)
|
||||||
|
|
||||||
|
# For the replacement see #29: text nodes underscores are escaped
|
||||||
|
if (
|
||||||
|
self.options["autolinks"]
|
||||||
|
and text.replace(r"\_", "_") == href
|
||||||
|
and not title
|
||||||
|
and not self.options["default_title"]
|
||||||
|
):
|
||||||
|
# Shortcut syntax
|
||||||
|
return "<%s>" % href
|
||||||
|
if self.options["default_title"] and not title:
|
||||||
|
title = href
|
||||||
|
title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
|
||||||
|
return (
|
||||||
|
"%s[%s](%s%s)%s" % (prefix, text, href, title_part, suffix)
|
||||||
|
if href
|
||||||
|
else text
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_img(
|
||||||
|
self,
|
||||||
|
el: Any,
|
||||||
|
text: str,
|
||||||
|
convert_as_inline: Optional[bool] = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
"""Same as usual converter, but removes data URIs"""
|
||||||
|
|
||||||
|
alt = el.attrs.get("alt", None) or ""
|
||||||
|
src = el.attrs.get("src", None) or el.attrs.get("data-src", None) or ""
|
||||||
|
title = el.attrs.get("title", None) or ""
|
||||||
|
title_part = ' "%s"' % title.replace('"', r"\"") if title else ""
|
||||||
|
# Remove all line breaks from alt
|
||||||
|
alt = alt.replace("\n", " ")
|
||||||
|
if (
|
||||||
|
convert_as_inline
|
||||||
|
and el.parent.name not in self.options["keep_inline_images_in"]
|
||||||
|
):
|
||||||
|
return alt
|
||||||
|
|
||||||
|
# Remove dataURIs
|
||||||
|
if src.startswith("data:") and not self.options["keep_data_uris"]:
|
||||||
|
src = src.split(",")[0] + "..."
|
||||||
|
|
||||||
|
return "" % (alt, src, title_part)
|
||||||
|
|
||||||
|
def convert_input(
|
||||||
|
self,
|
||||||
|
el: Any,
|
||||||
|
text: str,
|
||||||
|
convert_as_inline: Optional[bool] = False,
|
||||||
|
**kwargs,
|
||||||
|
) -> str:
|
||||||
|
"""Convert checkboxes to Markdown [x]/[ ] syntax."""
|
||||||
|
|
||||||
|
if el.get("type") == "checkbox":
|
||||||
|
return "[x] " if el.has_attr("checked") else "[ ] "
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def convert_soup(self, soup: Any) -> str:
|
||||||
|
return super().convert_soup(soup) # type: ignore
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import sys
|
||||||
|
from typing import Any, Union, BinaryIO
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
olefile = None
|
||||||
|
try:
|
||||||
|
import olefile # type: ignore[no-redef]
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/vnd.ms-outlook",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".msg"]
|
||||||
|
|
||||||
|
|
||||||
|
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 accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
# Check the extension and mimetype
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Brute force, check if we have an OLE file
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
if olefile and not olefile.isOleFile(file_stream):
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
# Brue force, check if it's an Outlook file
|
||||||
|
try:
|
||||||
|
if olefile is not None:
|
||||||
|
msg = olefile.OleFileIO(file_stream)
|
||||||
|
toc = "\n".join([str(stream) for stream in msg.listdir()])
|
||||||
|
return (
|
||||||
|
"__properties_version1.0" in toc
|
||||||
|
and "__recip_version1.0_#00000000" in toc
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Check: the dependencies
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".msg",
|
||||||
|
feature="outlook",
|
||||||
|
)
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
olefile is not None
|
||||||
|
) # If we made it this far, olefile should be available
|
||||||
|
msg = olefile.OleFileIO(file_stream)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
markdown=md_content.strip(),
|
||||||
|
title=headers.get("Subject"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_stream_data(self, msg: Any, stream_path: str) -> Union[str, None]:
|
||||||
|
"""Helper to safely extract and decode stream data from the MSG file."""
|
||||||
|
assert olefile is not None
|
||||||
|
assert isinstance(
|
||||||
|
msg, olefile.OleFileIO
|
||||||
|
) # Ensure msg is of the correct type (type hinting is not possible with the optional olefile package)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import pdfminer
|
||||||
|
import pdfminer.high_level
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/x-pdf",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".pdf"]
|
||||||
|
|
||||||
|
|
||||||
|
class PdfConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts PDFs to Markdown. Most style information is ignored, so the results are essentially plain-text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Check the dependencies
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".pdf",
|
||||||
|
feature="pdf",
|
||||||
|
)
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(file_stream, io.IOBase) # for mypy
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=pdfminer.high_level.extract_text(file_stream),
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from charset_normalizer import from_bytes
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import mammoth # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/",
|
||||||
|
"application/json",
|
||||||
|
"application/markdown",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".txt",
|
||||||
|
".text",
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".json",
|
||||||
|
".jsonl",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextConverter(DocumentConverter):
|
||||||
|
"""Anything with content type text/plain"""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
# If we have a charset, we can safely assume it's text
|
||||||
|
# With Magika in the earlier stages, this handles most cases
|
||||||
|
if stream_info.charset is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Otherwise, check the mimetype and extension
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
if stream_info.charset:
|
||||||
|
text_content = file_stream.read().decode(stream_info.charset)
|
||||||
|
else:
|
||||||
|
text_content = str(from_bytes(file_stream.read()).best())
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=text_content)
|
||||||
264
packages/markitdown/src/markitdown/converters/_pptx_converter.py
Normal file
264
packages/markitdown/src/markitdown/converters/_pptx_converter.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import sys
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
from ._html_converter import HtmlConverter
|
||||||
|
from ._llm_caption import llm_caption
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import pptx
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".pptx"]
|
||||||
|
|
||||||
|
|
||||||
|
class PptxConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts PPTX files to Markdown. Supports heading, tables and images with alt text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._html_converter = HtmlConverter()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Check the dependencies
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".pptx",
|
||||||
|
feature="pptx",
|
||||||
|
)
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform the conversion
|
||||||
|
presentation = pptx.Presentation(file_stream)
|
||||||
|
md_content = ""
|
||||||
|
slide_num = 0
|
||||||
|
for slide in presentation.slides:
|
||||||
|
slide_num += 1
|
||||||
|
|
||||||
|
md_content += f"\n\n<!-- Slide number: {slide_num} -->\n"
|
||||||
|
|
||||||
|
title = slide.shapes.title
|
||||||
|
|
||||||
|
def get_shape_content(shape, **kwargs):
|
||||||
|
nonlocal md_content
|
||||||
|
# Pictures
|
||||||
|
if self._is_picture(shape):
|
||||||
|
# https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069
|
||||||
|
|
||||||
|
llm_description = ""
|
||||||
|
alt_text = ""
|
||||||
|
|
||||||
|
# Potentially generate a description using an LLM
|
||||||
|
llm_client = kwargs.get("llm_client")
|
||||||
|
llm_model = kwargs.get("llm_model")
|
||||||
|
if llm_client is not None and llm_model is not None:
|
||||||
|
# Prepare a file_stream and stream_info for the image data
|
||||||
|
image_filename = shape.image.filename
|
||||||
|
image_extension = None
|
||||||
|
if image_filename:
|
||||||
|
image_extension = os.path.splitext(image_filename)[1]
|
||||||
|
image_stream_info = StreamInfo(
|
||||||
|
mimetype=shape.image.content_type,
|
||||||
|
extension=image_extension,
|
||||||
|
filename=image_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
image_stream = io.BytesIO(shape.image.blob)
|
||||||
|
|
||||||
|
# Caption the image
|
||||||
|
try:
|
||||||
|
llm_description = llm_caption(
|
||||||
|
image_stream,
|
||||||
|
image_stream_info,
|
||||||
|
client=llm_client,
|
||||||
|
model=llm_model,
|
||||||
|
prompt=kwargs.get("llm_prompt"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Unable to generate a description
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also grab any description embedded in the deck
|
||||||
|
try:
|
||||||
|
alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "")
|
||||||
|
except Exception:
|
||||||
|
# Unable to get alt text
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Prepare the alt, escaping any special characters
|
||||||
|
alt_text = "\n".join([llm_description, alt_text]) or shape.name
|
||||||
|
alt_text = re.sub(r"[\r\n\[\]]", " ", alt_text)
|
||||||
|
alt_text = re.sub(r"\s+", " ", alt_text).strip()
|
||||||
|
|
||||||
|
# If keep_data_uris is True, use base64 encoding for images
|
||||||
|
if kwargs.get("keep_data_uris", False):
|
||||||
|
blob = shape.image.blob
|
||||||
|
content_type = shape.image.content_type or "image/png"
|
||||||
|
b64_string = base64.b64encode(blob).decode("utf-8")
|
||||||
|
md_content += f"\n\n"
|
||||||
|
else:
|
||||||
|
# A placeholder name
|
||||||
|
filename = re.sub(r"\W", "", shape.name) + ".jpg"
|
||||||
|
md_content += "\n\n"
|
||||||
|
|
||||||
|
# Tables
|
||||||
|
if self._is_table(shape):
|
||||||
|
md_content += self._convert_table_to_markdown(shape.table, **kwargs)
|
||||||
|
|
||||||
|
# Charts
|
||||||
|
if shape.has_chart:
|
||||||
|
md_content += self._convert_chart_to_markdown(shape.chart)
|
||||||
|
|
||||||
|
# Text areas
|
||||||
|
elif shape.has_text_frame:
|
||||||
|
if shape == title:
|
||||||
|
md_content += "# " + shape.text.lstrip() + "\n"
|
||||||
|
else:
|
||||||
|
md_content += shape.text + "\n"
|
||||||
|
|
||||||
|
# Group Shapes
|
||||||
|
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP:
|
||||||
|
sorted_shapes = sorted(
|
||||||
|
shape.shapes,
|
||||||
|
key=lambda x: (
|
||||||
|
float("-inf") if not x.top else x.top,
|
||||||
|
float("-inf") if not x.left else x.left,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for subshape in sorted_shapes:
|
||||||
|
get_shape_content(subshape, **kwargs)
|
||||||
|
|
||||||
|
sorted_shapes = sorted(
|
||||||
|
slide.shapes,
|
||||||
|
key=lambda x: (
|
||||||
|
float("-inf") if not x.top else x.top,
|
||||||
|
float("-inf") if not x.left else x.left,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for shape in sorted_shapes:
|
||||||
|
get_shape_content(shape, **kwargs)
|
||||||
|
|
||||||
|
md_content = md_content.strip()
|
||||||
|
|
||||||
|
if slide.has_notes_slide:
|
||||||
|
md_content += "\n\n### Notes:\n"
|
||||||
|
notes_frame = slide.notes_slide.notes_text_frame
|
||||||
|
if notes_frame is not None:
|
||||||
|
md_content += notes_frame.text
|
||||||
|
md_content = md_content.strip()
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=md_content.strip())
|
||||||
|
|
||||||
|
def _is_picture(self, shape):
|
||||||
|
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE:
|
||||||
|
return True
|
||||||
|
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER:
|
||||||
|
if hasattr(shape, "image"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_table(self, shape):
|
||||||
|
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _convert_table_to_markdown(self, table, **kwargs):
|
||||||
|
# Write the table as HTML, then convert it to Markdown
|
||||||
|
html_table = "<html><body><table>"
|
||||||
|
first_row = True
|
||||||
|
for row in table.rows:
|
||||||
|
html_table += "<tr>"
|
||||||
|
for cell in row.cells:
|
||||||
|
if first_row:
|
||||||
|
html_table += "<th>" + html.escape(cell.text) + "</th>"
|
||||||
|
else:
|
||||||
|
html_table += "<td>" + html.escape(cell.text) + "</td>"
|
||||||
|
html_table += "</tr>"
|
||||||
|
first_row = False
|
||||||
|
html_table += "</table></body></html>"
|
||||||
|
|
||||||
|
return (
|
||||||
|
self._html_converter.convert_string(html_table, **kwargs).markdown.strip()
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _convert_chart_to_markdown(self, chart):
|
||||||
|
try:
|
||||||
|
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:])
|
||||||
|
except ValueError as e:
|
||||||
|
# Handle the specific error for unsupported chart types
|
||||||
|
if "unsupported plot type" in str(e):
|
||||||
|
return "\n\n[unsupported chart]\n\n"
|
||||||
|
except Exception:
|
||||||
|
# Catch any other exceptions that might occur
|
||||||
|
return "\n\n[unsupported chart]\n\n"
|
||||||
192
packages/markitdown/src/markitdown/converters/_rss_converter.py
Normal file
192
packages/markitdown/src/markitdown/converters/_rss_converter.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
from defusedxml import minidom
|
||||||
|
from xml.dom.minidom import Document, Element
|
||||||
|
from typing import BinaryIO, Any, Union
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from ._markdownify import _CustomMarkdownify
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
|
||||||
|
PRECISE_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/rss",
|
||||||
|
"application/rss+xml",
|
||||||
|
"application/atom",
|
||||||
|
"application/atom+xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
PRECISE_FILE_EXTENSIONS = [".rss", ".atom"]
|
||||||
|
|
||||||
|
CANDIDATE_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/xml",
|
||||||
|
"application/xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
CANDIDATE_FILE_EXTENSIONS = [
|
||||||
|
".xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RssConverter(DocumentConverter):
|
||||||
|
"""Convert RSS / Atom type to markdown"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._kwargs = {}
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
# Check for precise mimetypes and file extensions
|
||||||
|
if extension in PRECISE_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in PRECISE_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for precise mimetypes and file extensions
|
||||||
|
if extension in CANDIDATE_FILE_EXTENSIONS:
|
||||||
|
return self._check_xml(file_stream)
|
||||||
|
|
||||||
|
for prefix in CANDIDATE_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return self._check_xml(file_stream)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_xml(self, file_stream: BinaryIO) -> bool:
|
||||||
|
cur_pos = file_stream.tell()
|
||||||
|
try:
|
||||||
|
doc = minidom.parse(file_stream)
|
||||||
|
return self._feed_type(doc) is not None
|
||||||
|
except BaseException as _:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
file_stream.seek(cur_pos)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _feed_type(self, doc: Any) -> str | None:
|
||||||
|
if doc.getElementsByTagName("rss"):
|
||||||
|
return "rss"
|
||||||
|
elif doc.getElementsByTagName("feed"):
|
||||||
|
root = doc.getElementsByTagName("feed")[0]
|
||||||
|
if root.getElementsByTagName("entry"):
|
||||||
|
# An Atom feed must have a root element of <feed> and at least one <entry>
|
||||||
|
return "atom"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
self._kwargs = kwargs
|
||||||
|
doc = minidom.parse(file_stream)
|
||||||
|
feed_type = self._feed_type(doc)
|
||||||
|
|
||||||
|
if feed_type == "rss":
|
||||||
|
return self._parse_rss_type(doc)
|
||||||
|
elif feed_type == "atom":
|
||||||
|
return self._parse_atom_type(doc)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown feed type")
|
||||||
|
|
||||||
|
def _parse_atom_type(self, doc: Document) -> DocumentConverterResult:
|
||||||
|
"""Parse the type of an Atom feed.
|
||||||
|
|
||||||
|
Returns None if the feed type is not recognized or something goes wrong.
|
||||||
|
"""
|
||||||
|
root = doc.getElementsByTagName("feed")[0]
|
||||||
|
title = self._get_data_by_tag_name(root, "title")
|
||||||
|
subtitle = self._get_data_by_tag_name(root, "subtitle")
|
||||||
|
entries = root.getElementsByTagName("entry")
|
||||||
|
md_text = f"# {title}\n"
|
||||||
|
if subtitle:
|
||||||
|
md_text += f"{subtitle}\n"
|
||||||
|
for entry in entries:
|
||||||
|
entry_title = self._get_data_by_tag_name(entry, "title")
|
||||||
|
entry_summary = self._get_data_by_tag_name(entry, "summary")
|
||||||
|
entry_updated = self._get_data_by_tag_name(entry, "updated")
|
||||||
|
entry_content = self._get_data_by_tag_name(entry, "content")
|
||||||
|
|
||||||
|
if entry_title:
|
||||||
|
md_text += f"\n## {entry_title}\n"
|
||||||
|
if entry_updated:
|
||||||
|
md_text += f"Updated on: {entry_updated}\n"
|
||||||
|
if entry_summary:
|
||||||
|
md_text += self._parse_content(entry_summary)
|
||||||
|
if entry_content:
|
||||||
|
md_text += self._parse_content(entry_content)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=md_text,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_rss_type(self, doc: Document) -> DocumentConverterResult:
|
||||||
|
"""Parse the type of an RSS feed.
|
||||||
|
|
||||||
|
Returns None if the feed type is not recognized or something goes wrong.
|
||||||
|
"""
|
||||||
|
root = doc.getElementsByTagName("rss")[0]
|
||||||
|
channel_list = root.getElementsByTagName("channel")
|
||||||
|
if not channel_list:
|
||||||
|
raise ValueError("No channel found in RSS feed")
|
||||||
|
channel = channel_list[0]
|
||||||
|
channel_title = self._get_data_by_tag_name(channel, "title")
|
||||||
|
channel_description = self._get_data_by_tag_name(channel, "description")
|
||||||
|
items = channel.getElementsByTagName("item")
|
||||||
|
if channel_title:
|
||||||
|
md_text = f"# {channel_title}\n"
|
||||||
|
if channel_description:
|
||||||
|
md_text += f"{channel_description}\n"
|
||||||
|
for item in items:
|
||||||
|
title = self._get_data_by_tag_name(item, "title")
|
||||||
|
description = self._get_data_by_tag_name(item, "description")
|
||||||
|
pubDate = self._get_data_by_tag_name(item, "pubDate")
|
||||||
|
content = self._get_data_by_tag_name(item, "content:encoded")
|
||||||
|
|
||||||
|
if title:
|
||||||
|
md_text += f"\n## {title}\n"
|
||||||
|
if pubDate:
|
||||||
|
md_text += f"Published on: {pubDate}\n"
|
||||||
|
if description:
|
||||||
|
md_text += self._parse_content(description)
|
||||||
|
if content:
|
||||||
|
md_text += self._parse_content(content)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=md_text,
|
||||||
|
title=channel_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_content(self, content: str) -> str:
|
||||||
|
"""Parse the content of an RSS feed item"""
|
||||||
|
try:
|
||||||
|
# using bs4 because many RSS feeds have HTML-styled content
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
return _CustomMarkdownify(**self._kwargs).convert_soup(soup)
|
||||||
|
except BaseException as _:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _get_data_by_tag_name(
|
||||||
|
self, element: Element, tag_name: str
|
||||||
|
) -> Union[str, None]:
|
||||||
|
"""Get data from first child element with the given tag name.
|
||||||
|
Returns None when no such element is found.
|
||||||
|
"""
|
||||||
|
nodes = element.getElementsByTagName(tag_name)
|
||||||
|
if not nodes:
|
||||||
|
return None
|
||||||
|
fc = nodes[0].firstChild
|
||||||
|
if fc:
|
||||||
|
if hasattr(fc, "data"):
|
||||||
|
return fc.data
|
||||||
|
return None
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from typing import BinaryIO
|
||||||
|
from .._exceptions import MissingDependencyException
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
# Suppress some warnings on library import
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||||
|
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||||
|
import speech_recognition as sr
|
||||||
|
import pydub
|
||||||
|
except ImportError:
|
||||||
|
# Preserve the error and stack trace for later
|
||||||
|
_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_audio(file_stream: BinaryIO, *, audio_format: str = "wav") -> str:
|
||||||
|
# Check for installed dependencies
|
||||||
|
if _dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
"Speech transcription requires installing MarkItdown with the [audio-transcription] optional dependencies. E.g., `pip install markitdown[audio-transcription]` or `pip install markitdown[all]`"
|
||||||
|
) from _dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
if audio_format in ["wav", "aiff", "flac"]:
|
||||||
|
audio_source = file_stream
|
||||||
|
elif audio_format in ["mp3", "mp4"]:
|
||||||
|
audio_segment = pydub.AudioSegment.from_file(file_stream, format=audio_format)
|
||||||
|
|
||||||
|
audio_source = io.BytesIO()
|
||||||
|
audio_segment.export(audio_source, format="wav")
|
||||||
|
audio_source.seek(0)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported audio format: {audio_format}")
|
||||||
|
|
||||||
|
recognizer = sr.Recognizer()
|
||||||
|
with sr.AudioFile(audio_source) as source:
|
||||||
|
audio = recognizer.record(source)
|
||||||
|
transcript = recognizer.recognize_google(audio).strip()
|
||||||
|
return "[No speech detected]" if transcript == "" else transcript
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import re
|
||||||
|
import bs4
|
||||||
|
from typing import Any, BinaryIO
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from ._markdownify import _CustomMarkdownify
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WikipediaConverter(DocumentConverter):
|
||||||
|
"""Handle Wikipedia pages separately, focusing only on the main document content."""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Make sure we're dealing with HTML content *from* Wikipedia.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = stream_info.url or ""
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if not re.search(r"^https?:\/\/[a-zA-Z]{2,3}\.wikipedia.org\/", url):
|
||||||
|
# Not a Wikipedia URL
|
||||||
|
return False
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Not HTML content
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Parse the stream
|
||||||
|
encoding = "utf-8" if stream_info.charset is None else stream_info.charset
|
||||||
|
soup = bs4.BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)
|
||||||
|
|
||||||
|
# Remove javascript and style blocks
|
||||||
|
for script in soup(["script", "style"]):
|
||||||
|
script.extract()
|
||||||
|
|
||||||
|
# Print only the main content
|
||||||
|
body_elm = soup.find("div", {"id": "mw-content-text"})
|
||||||
|
title_elm = soup.find("span", {"class": "mw-page-title-main"})
|
||||||
|
|
||||||
|
webpage_text = ""
|
||||||
|
main_title = None if soup.title is None else soup.title.string
|
||||||
|
|
||||||
|
if body_elm:
|
||||||
|
# What's the title
|
||||||
|
if title_elm and isinstance(title_elm, bs4.Tag):
|
||||||
|
main_title = title_elm.string
|
||||||
|
|
||||||
|
# Convert the page
|
||||||
|
webpage_text = f"# {main_title}\n\n" + _CustomMarkdownify(
|
||||||
|
**kwargs
|
||||||
|
).convert_soup(body_elm)
|
||||||
|
else:
|
||||||
|
webpage_text = _CustomMarkdownify(**kwargs).convert_soup(soup)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=webpage_text,
|
||||||
|
title=main_title,
|
||||||
|
)
|
||||||
157
packages/markitdown/src/markitdown/converters/_xlsx_converter.py
Normal file
157
packages/markitdown/src/markitdown/converters/_xlsx_converter.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import sys
|
||||||
|
from typing import BinaryIO, Any
|
||||||
|
from ._html_converter import HtmlConverter
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
# Try loading optional (but in this case, required) dependencies
|
||||||
|
# Save reporting of any exceptions for later
|
||||||
|
_xlsx_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
import openpyxl # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
_xlsx_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
_xls_dependency_exc_info = None
|
||||||
|
try:
|
||||||
|
import pandas as pd # noqa: F811
|
||||||
|
import xlrd # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
_xls_dependency_exc_info = sys.exc_info()
|
||||||
|
|
||||||
|
ACCEPTED_XLSX_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
]
|
||||||
|
ACCEPTED_XLSX_FILE_EXTENSIONS = [".xlsx"]
|
||||||
|
|
||||||
|
ACCEPTED_XLS_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/excel",
|
||||||
|
]
|
||||||
|
ACCEPTED_XLS_FILE_EXTENSIONS = [".xls"]
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._html_converter = HtmlConverter()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_XLSX_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_XLSX_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Check the dependencies
|
||||||
|
if _xlsx_dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".xlsx",
|
||||||
|
feature="xlsx",
|
||||||
|
)
|
||||||
|
) from _xlsx_dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_xlsx_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
sheets = pd.read_excel(file_stream, 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._html_converter.convert_string(
|
||||||
|
html_content, **kwargs
|
||||||
|
).markdown.strip()
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=md_content.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class XlsConverter(DocumentConverter):
|
||||||
|
"""
|
||||||
|
Converts XLS files to Markdown, with each sheet presented as a separate Markdown table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._html_converter = HtmlConverter()
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_XLS_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_XLS_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Load the dependencies
|
||||||
|
if _xls_dependency_exc_info is not None:
|
||||||
|
raise MissingDependencyException(
|
||||||
|
MISSING_DEPENDENCY_MESSAGE.format(
|
||||||
|
converter=type(self).__name__,
|
||||||
|
extension=".xls",
|
||||||
|
feature="xls",
|
||||||
|
)
|
||||||
|
) from _xls_dependency_exc_info[
|
||||||
|
1
|
||||||
|
].with_traceback( # type: ignore[union-attr]
|
||||||
|
_xls_dependency_exc_info[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
sheets = pd.read_excel(file_stream, sheet_name=None, engine="xlrd")
|
||||||
|
md_content = ""
|
||||||
|
for s in sheets:
|
||||||
|
md_content += f"## {s}\n"
|
||||||
|
html_content = sheets[s].to_html(index=False)
|
||||||
|
md_content += (
|
||||||
|
self._html_converter.convert_string(
|
||||||
|
html_content, **kwargs
|
||||||
|
).markdown.strip()
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=md_content.strip())
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import bs4
|
||||||
|
from typing import Any, BinaryIO, Dict, List, Union
|
||||||
|
from urllib.parse import parse_qs, urlparse, unquote
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
|
||||||
|
# Optional YouTube transcription support
|
||||||
|
try:
|
||||||
|
# Suppress some warnings on library import
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=SyntaxWarning)
|
||||||
|
# Patch submitted upstream to fix the SyntaxWarning
|
||||||
|
from youtube_transcript_api import YouTubeTranscriptApi
|
||||||
|
|
||||||
|
IS_YOUTUBE_TRANSCRIPT_CAPABLE = True
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
IS_YOUTUBE_TRANSCRIPT_CAPABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeConverter(DocumentConverter):
|
||||||
|
"""Handle YouTube specially, focusing on the video title, description, and transcript."""
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Make sure we're dealing with HTML content *from* YouTube.
|
||||||
|
"""
|
||||||
|
url = stream_info.url or ""
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
url = unquote(url)
|
||||||
|
url = url.replace(r"\?", "?").replace(r"\=", "=")
|
||||||
|
|
||||||
|
if not url.startswith("https://www.youtube.com/watch?"):
|
||||||
|
# Not a YouTube URL
|
||||||
|
return False
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Not HTML content
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
# Parse the stream
|
||||||
|
encoding = "utf-8" if stream_info.charset is None else stream_info.charset
|
||||||
|
soup = bs4.BeautifulSoup(file_stream, "html.parser", from_encoding=encoding)
|
||||||
|
|
||||||
|
# Read the meta tags
|
||||||
|
metadata: Dict[str, str] = {}
|
||||||
|
|
||||||
|
if soup.title and soup.title.string:
|
||||||
|
metadata["title"] = soup.title.string
|
||||||
|
|
||||||
|
for meta in soup(["meta"]):
|
||||||
|
if not isinstance(meta, bs4.Tag):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for a in meta.attrs:
|
||||||
|
if a in ["itemprop", "property", "name"]:
|
||||||
|
key = str(meta.get(a, ""))
|
||||||
|
content = str(meta.get("content", ""))
|
||||||
|
if key and content: # Only add non-empty content
|
||||||
|
metadata[key] = content
|
||||||
|
break
|
||||||
|
|
||||||
|
# Try reading the description
|
||||||
|
try:
|
||||||
|
for script in soup(["script"]):
|
||||||
|
if not isinstance(script, bs4.Tag):
|
||||||
|
continue
|
||||||
|
if not script.string: # Skip empty scripts
|
||||||
|
continue
|
||||||
|
content = script.string
|
||||||
|
if "ytInitialData" in content:
|
||||||
|
match = re.search(r"var ytInitialData = ({.*?});", content)
|
||||||
|
if match:
|
||||||
|
data = json.loads(match.group(1))
|
||||||
|
attrdesc = self._findKey(data, "attributedDescriptionBodyText")
|
||||||
|
if attrdesc and isinstance(attrdesc, dict):
|
||||||
|
metadata["description"] = str(attrdesc.get("content", ""))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting description: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Start preparing the page
|
||||||
|
webpage_text = "# YouTube\n"
|
||||||
|
|
||||||
|
title = self._get(metadata, ["title", "og:title", "name"]) # type: ignore
|
||||||
|
assert isinstance(title, str)
|
||||||
|
|
||||||
|
if title:
|
||||||
|
webpage_text += f"\n## {title}\n"
|
||||||
|
|
||||||
|
stats = ""
|
||||||
|
views = self._get(metadata, ["interactionCount"]) # type: ignore
|
||||||
|
if views:
|
||||||
|
stats += f"- **Views:** {views}\n"
|
||||||
|
|
||||||
|
keywords = self._get(metadata, ["keywords"]) # type: ignore
|
||||||
|
if keywords:
|
||||||
|
stats += f"- **Keywords:** {keywords}\n"
|
||||||
|
|
||||||
|
runtime = self._get(metadata, ["duration"]) # type: ignore
|
||||||
|
if runtime:
|
||||||
|
stats += f"- **Runtime:** {runtime}\n"
|
||||||
|
|
||||||
|
if len(stats) > 0:
|
||||||
|
webpage_text += f"\n### Video Metadata\n{stats}\n"
|
||||||
|
|
||||||
|
description = self._get(metadata, ["description", "og:description"]) # type: ignore
|
||||||
|
if description:
|
||||||
|
webpage_text += f"\n### Description\n{description}\n"
|
||||||
|
|
||||||
|
if IS_YOUTUBE_TRANSCRIPT_CAPABLE:
|
||||||
|
ytt_api = YouTubeTranscriptApi()
|
||||||
|
transcript_text = ""
|
||||||
|
parsed_url = urlparse(stream_info.url) # type: ignore
|
||||||
|
params = parse_qs(parsed_url.query) # type: ignore
|
||||||
|
if "v" in params and params["v"][0]:
|
||||||
|
video_id = str(params["v"][0])
|
||||||
|
transcript_list = ytt_api.list(video_id)
|
||||||
|
languages = ["en"]
|
||||||
|
for transcript in transcript_list:
|
||||||
|
languages.append(transcript.language_code)
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
youtube_transcript_languages = kwargs.get(
|
||||||
|
"youtube_transcript_languages", languages
|
||||||
|
)
|
||||||
|
# Retry the transcript fetching operation
|
||||||
|
transcript = self._retry_operation(
|
||||||
|
lambda: ytt_api.fetch(
|
||||||
|
video_id, languages=youtube_transcript_languages
|
||||||
|
),
|
||||||
|
retries=3, # Retry 3 times
|
||||||
|
delay=2, # 2 seconds delay between retries
|
||||||
|
)
|
||||||
|
|
||||||
|
if transcript:
|
||||||
|
transcript_text = " ".join(
|
||||||
|
[part.text for part in transcript]
|
||||||
|
) # type: ignore
|
||||||
|
except Exception as e:
|
||||||
|
# No transcript available
|
||||||
|
if len(languages) == 1:
|
||||||
|
print(f"Error fetching transcript: {e}")
|
||||||
|
else:
|
||||||
|
# Translate transcript into first kwarg
|
||||||
|
transcript = (
|
||||||
|
transcript_list.find_transcript(languages)
|
||||||
|
.translate(youtube_transcript_languages[0])
|
||||||
|
.fetch()
|
||||||
|
)
|
||||||
|
transcript_text = " ".join([part.text for part in transcript])
|
||||||
|
if transcript_text:
|
||||||
|
webpage_text += f"\n### Transcript\n{transcript_text}\n"
|
||||||
|
|
||||||
|
title = title if title else (soup.title.string if soup.title else "")
|
||||||
|
assert isinstance(title, str)
|
||||||
|
|
||||||
|
return DocumentConverterResult(
|
||||||
|
markdown=webpage_text,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get(
|
||||||
|
self,
|
||||||
|
metadata: Dict[str, str],
|
||||||
|
keys: List[str],
|
||||||
|
default: Union[str, None] = None,
|
||||||
|
) -> Union[str, None]:
|
||||||
|
"""Get first non-empty value from metadata matching given keys."""
|
||||||
|
for k in keys:
|
||||||
|
if k in metadata:
|
||||||
|
return metadata[k]
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _findKey(self, json: Any, key: str) -> Union[str, None]: # TODO: Fix json type
|
||||||
|
"""Recursively search for a key in nested dictionary/list structures."""
|
||||||
|
if isinstance(json, list):
|
||||||
|
for elm in json:
|
||||||
|
ret = self._findKey(elm, key)
|
||||||
|
if ret is not None:
|
||||||
|
return ret
|
||||||
|
elif isinstance(json, dict):
|
||||||
|
for k, v in json.items():
|
||||||
|
if k == key:
|
||||||
|
return json[k]
|
||||||
|
if result := self._findKey(v, key):
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _retry_operation(self, operation, retries=3, delay=2):
|
||||||
|
"""Retries the operation if it fails."""
|
||||||
|
attempt = 0
|
||||||
|
while attempt < retries:
|
||||||
|
try:
|
||||||
|
return operation() # Attempt the operation
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Attempt {attempt + 1} failed: {e}")
|
||||||
|
if attempt < retries - 1:
|
||||||
|
time.sleep(delay) # Wait before retrying
|
||||||
|
attempt += 1
|
||||||
|
# If all attempts fail, raise the last exception
|
||||||
|
raise Exception(f"Operation failed after {retries} attempts.")
|
||||||
116
packages/markitdown/src/markitdown/converters/_zip_converter.py
Normal file
116
packages/markitdown/src/markitdown/converters/_zip_converter.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
from typing import BinaryIO, Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
from .._base_converter import DocumentConverter, DocumentConverterResult
|
||||||
|
from .._stream_info import StreamInfo
|
||||||
|
from .._exceptions import UnsupportedFormatException, FileConversionException
|
||||||
|
|
||||||
|
# Break otherwise circular import for type hinting
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .._markitdown import MarkItDown
|
||||||
|
|
||||||
|
ACCEPTED_MIME_TYPE_PREFIXES = [
|
||||||
|
"application/zip",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPTED_FILE_EXTENSIONS = [".zip"]
|
||||||
|
|
||||||
|
|
||||||
|
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 __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
markitdown: "MarkItDown",
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self._markitdown = markitdown
|
||||||
|
|
||||||
|
def accepts(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> bool:
|
||||||
|
mimetype = (stream_info.mimetype or "").lower()
|
||||||
|
extension = (stream_info.extension or "").lower()
|
||||||
|
|
||||||
|
if extension in ACCEPTED_FILE_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
|
||||||
|
if mimetype.startswith(prefix):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self,
|
||||||
|
file_stream: BinaryIO,
|
||||||
|
stream_info: StreamInfo,
|
||||||
|
**kwargs: Any, # Options to pass to the converter
|
||||||
|
) -> DocumentConverterResult:
|
||||||
|
file_path = stream_info.url or stream_info.local_path or stream_info.filename
|
||||||
|
md_content = f"Content from the zip file `{file_path}`:\n\n"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(file_stream, "r") as zipObj:
|
||||||
|
for name in zipObj.namelist():
|
||||||
|
try:
|
||||||
|
z_file_stream = io.BytesIO(zipObj.read(name))
|
||||||
|
z_file_stream_info = StreamInfo(
|
||||||
|
extension=os.path.splitext(name)[1],
|
||||||
|
filename=os.path.basename(name),
|
||||||
|
)
|
||||||
|
result = self._markitdown.convert_stream(
|
||||||
|
stream=z_file_stream,
|
||||||
|
stream_info=z_file_stream_info,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
md_content += f"## File: {name}\n\n"
|
||||||
|
md_content += result.markdown + "\n\n"
|
||||||
|
except UnsupportedFormatException:
|
||||||
|
pass
|
||||||
|
except FileConversionException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return DocumentConverterResult(markdown=md_content.strip())
|
||||||
0
packages/markitdown/src/markitdown/py.typed
Normal file
0
packages/markitdown/src/markitdown/py.typed
Normal file
3
packages/markitdown/tests/__init__.py
Normal file
3
packages/markitdown/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
279
packages/markitdown/tests/_test_vectors.py
Normal file
279
packages/markitdown/tests/_test_vectors.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import dataclasses
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||||
|
class FileTestVector(object):
|
||||||
|
filename: str
|
||||||
|
mimetype: str | None
|
||||||
|
charset: str | None
|
||||||
|
url: str | None
|
||||||
|
must_include: List[str]
|
||||||
|
must_not_include: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
GENERAL_TEST_VECTORS = [
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.docx",
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"314b0a30-5b04-470b-b9f7-eed2c2bec74a",
|
||||||
|
"49e168b7-d2ae-407f-a055-2167576f39a1",
|
||||||
|
"## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
|
||||||
|
"# Abstract",
|
||||||
|
"# Introduction",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"data:image/png;base64...",
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSU",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.xlsx",
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"## 09060124-b5e7-4717-9d07-3c046eb",
|
||||||
|
"6ff4173b-42a5-4784-9b19-f49caff4d93d",
|
||||||
|
"affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.xls",
|
||||||
|
mimetype="application/vnd.ms-excel",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"## 09060124-b5e7-4717-9d07-3c046eb",
|
||||||
|
"6ff4173b-42a5-4784-9b19-f49caff4d93d",
|
||||||
|
"affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.pptx",
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"2cdda5c8-e50e-4db4-b5f0-9722a649f455",
|
||||||
|
"04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
|
||||||
|
"44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
|
||||||
|
"1b92870d-e3b5-4e65-8153-919f4ff45592",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"a3f6004b-6f4f-4ea8-bee3-3741f4dc385f", # chart title
|
||||||
|
"2003", # chart value
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
must_not_include=["data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE"],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_outlook_msg.msg",
|
||||||
|
mimetype="application/vnd.ms-outlook",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"# Email Message",
|
||||||
|
"**From:** test.sender@example.com",
|
||||||
|
"**To:** test.recipient@example.com",
|
||||||
|
"**Subject:** Test Email Message",
|
||||||
|
"## Content",
|
||||||
|
"This is the body of the test email message",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.pdf",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"While there is contemporaneous exploration of multi-agent approaches"
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_blog.html",
|
||||||
|
mimetype="text/html",
|
||||||
|
charset="utf-8",
|
||||||
|
url="https://microsoft.github.io/autogen/blog/2023/04/21/LLM-tuning-math",
|
||||||
|
must_include=[
|
||||||
|
"Large language models (LLMs) are powerful tools that can generate natural language texts for various applications, such as chatbots, summarization, translation, and more. GPT-4 is currently the state of the art LLM in the world. Is model selection irrelevant? What about inference parameters?",
|
||||||
|
"an example where high cost can easily prevent a generic complex",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_wikipedia.html",
|
||||||
|
mimetype="text/html",
|
||||||
|
charset="utf-8",
|
||||||
|
url="https://en.wikipedia.org/wiki/Microsoft",
|
||||||
|
must_include=[
|
||||||
|
"Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
|
||||||
|
'Microsoft was founded by [Bill Gates](/wiki/Bill_Gates "Bill Gates")',
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"You are encouraged to create an account and log in",
|
||||||
|
"154 languages",
|
||||||
|
"move to sidebar",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_serp.html",
|
||||||
|
mimetype="text/html",
|
||||||
|
charset="utf-8",
|
||||||
|
url="https://www.bing.com/search?q=microsoft+wikipedia",
|
||||||
|
must_include=[
|
||||||
|
"](https://en.wikipedia.org/wiki/Microsoft",
|
||||||
|
"Microsoft Corporation is **an American multinational corporation and technology company headquartered** in Redmond",
|
||||||
|
"1995–2007: Foray into the Web, Windows 95, Windows XP, and Xbox",
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"https://www.bing.com/ck/a?!&&p=",
|
||||||
|
"data:image/svg+xml,%3Csvg%20width%3D",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_mskanji.csv",
|
||||||
|
mimetype="text/csv",
|
||||||
|
charset="cp932",
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"| 名前 | 年齢 | 住所 |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
"| 佐藤太郎 | 30 | 東京 |",
|
||||||
|
"| 三木英子 | 25 | 大阪 |",
|
||||||
|
"| 髙橋淳 | 35 | 名古屋 |",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.json",
|
||||||
|
mimetype="application/json",
|
||||||
|
charset="ascii",
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"5b64c88c-b3c3-4510-bcb8-da0b200602d8",
|
||||||
|
"9700dc99-6685-40b4-9a3a-5e406dcb37f3",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_rss.xml",
|
||||||
|
mimetype="text/xml",
|
||||||
|
charset="utf-8",
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"# The Official Microsoft Blog",
|
||||||
|
"## Ignite 2024: Why nearly 70% of the Fortune 500 now use Microsoft 365 Copilot",
|
||||||
|
"In the case of AI, it is absolutely true that the industry is moving incredibly fast",
|
||||||
|
],
|
||||||
|
must_not_include=["<rss", "<feed"],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_notebook.ipynb",
|
||||||
|
mimetype="application/json",
|
||||||
|
charset="ascii",
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"# Test Notebook",
|
||||||
|
"```python",
|
||||||
|
'print("markitdown")',
|
||||||
|
"```",
|
||||||
|
"## Code Cell Below",
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"nbformat",
|
||||||
|
"nbformat_minor",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test_files.zip",
|
||||||
|
mimetype="application/zip",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"314b0a30-5b04-470b-b9f7-eed2c2bec74a",
|
||||||
|
"49e168b7-d2ae-407f-a055-2167576f39a1",
|
||||||
|
"## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
|
||||||
|
"# Abstract",
|
||||||
|
"# Introduction",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"2cdda5c8-e50e-4db4-b5f0-9722a649f455",
|
||||||
|
"04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
|
||||||
|
"44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
|
||||||
|
"1b92870d-e3b5-4e65-8153-919f4ff45592",
|
||||||
|
"## 09060124-b5e7-4717-9d07-3c046eb",
|
||||||
|
"6ff4173b-42a5-4784-9b19-f49caff4d93d",
|
||||||
|
"affc7dad-52dc-4b98-9b5d-51e65d8a8ad0",
|
||||||
|
"Microsoft entered the operating system (OS) business in 1980 with its own version of [Unix]",
|
||||||
|
'Microsoft was founded by [Bill Gates](/wiki/Bill_Gates "Bill Gates")',
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.epub",
|
||||||
|
mimetype="application/epub+zip",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"**Authors:** Test Author",
|
||||||
|
"A test EPUB document for MarkItDown testing",
|
||||||
|
"# Chapter 1: Test Content",
|
||||||
|
"This is a **test** paragraph with some formatting",
|
||||||
|
"* A bullet point",
|
||||||
|
"* Another point",
|
||||||
|
"# Chapter 2: More Content",
|
||||||
|
"*different* style",
|
||||||
|
"> This is a blockquote for testing",
|
||||||
|
],
|
||||||
|
must_not_include=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
DATA_URI_TEST_VECTORS = [
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.docx",
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"314b0a30-5b04-470b-b9f7-eed2c2bec74a",
|
||||||
|
"49e168b7-d2ae-407f-a055-2167576f39a1",
|
||||||
|
"## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
|
||||||
|
"# Abstract",
|
||||||
|
"# Introduction",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"data:image/png;base64,iVBORw0KGgoAAAANSU",
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"data:image/png;base64...",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
FileTestVector(
|
||||||
|
filename="test.pptx",
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
charset=None,
|
||||||
|
url=None,
|
||||||
|
must_include=[
|
||||||
|
"2cdda5c8-e50e-4db4-b5f0-9722a649f455",
|
||||||
|
"04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
|
||||||
|
"44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
|
||||||
|
"1b92870d-e3b5-4e65-8153-919f4ff45592",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"a3f6004b-6f4f-4ea8-bee3-3741f4dc385f", # chart title
|
||||||
|
"2003", # chart value
|
||||||
|
"![This phrase of the caption is Human-written.]", # image caption
|
||||||
|
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQE",
|
||||||
|
],
|
||||||
|
must_not_include=[
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
34
packages/markitdown/tests/test_cli_misc.py
Normal file
34
packages/markitdown/tests/test_cli_misc.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3 -m pytest
|
||||||
|
import subprocess
|
||||||
|
from markitdown import __version__
|
||||||
|
|
||||||
|
# This file contains CLI tests that are not directly tested by the FileTestVectors.
|
||||||
|
# This includes things like help messages, version numbers, and invalid flags.
|
||||||
|
|
||||||
|
|
||||||
|
def test_version() -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "markitdown", "--version"], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
assert __version__ in result.stdout, f"Version not found in output: {result.stdout}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_flag() -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "markitdown", "--foobar"], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode != 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
assert (
|
||||||
|
"unrecognized arguments" in result.stderr
|
||||||
|
), "Expected 'unrecognized arguments' to appear in STDERR"
|
||||||
|
assert "SYNTAX" in result.stderr, "Expected 'SYNTAX' to appear in STDERR"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
"""Runs this file's tests from the command line."""
|
||||||
|
test_version()
|
||||||
|
test_invalid_flag()
|
||||||
|
print("All tests passed!")
|
||||||
217
packages/markitdown/tests/test_cli_vectors.py
Normal file
217
packages/markitdown/tests/test_cli_vectors.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3 -m pytest
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
import subprocess
|
||||||
|
import locale
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from _test_vectors import (
|
||||||
|
GENERAL_TEST_VECTORS,
|
||||||
|
DATA_URI_TEST_VECTORS,
|
||||||
|
FileTestVector,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from ._test_vectors import (
|
||||||
|
GENERAL_TEST_VECTORS,
|
||||||
|
DATA_URI_TEST_VECTORS,
|
||||||
|
FileTestVector,
|
||||||
|
)
|
||||||
|
|
||||||
|
skip_remote = (
|
||||||
|
True if os.environ.get("GITHUB_ACTIONS") else False
|
||||||
|
) # Don't run these tests in CI
|
||||||
|
|
||||||
|
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "test_files")
|
||||||
|
TEST_FILES_URL = "https://raw.githubusercontent.com/microsoft/markitdown/refs/heads/main/packages/markitdown/tests/test_files"
|
||||||
|
|
||||||
|
|
||||||
|
# Prepare CLI test vectors (remove vectors that require mockig the url)
|
||||||
|
CLI_TEST_VECTORS: List[FileTestVector] = []
|
||||||
|
for test_vector in GENERAL_TEST_VECTORS:
|
||||||
|
if test_vector.url is not None:
|
||||||
|
continue
|
||||||
|
CLI_TEST_VECTORS.append(test_vector)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def shared_tmp_dir(tmp_path_factory):
|
||||||
|
return tmp_path_factory.mktemp("pytest_tmp")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", CLI_TEST_VECTORS)
|
||||||
|
def test_output_to_stdout(shared_tmp_dir, test_vector) -> None:
|
||||||
|
"""Test that the CLI outputs to stdout correctly."""
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"markitdown",
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
for test_string in test_vector.must_include:
|
||||||
|
assert test_string in result.stdout
|
||||||
|
for test_string in test_vector.must_not_include:
|
||||||
|
assert test_string not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", CLI_TEST_VECTORS)
|
||||||
|
def test_output_to_file(shared_tmp_dir, test_vector) -> None:
|
||||||
|
"""Test that the CLI outputs to a file correctly."""
|
||||||
|
|
||||||
|
output_file = os.path.join(shared_tmp_dir, test_vector.filename + ".output")
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"markitdown",
|
||||||
|
"-o",
|
||||||
|
output_file,
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
assert os.path.exists(output_file), f"Output file not created: {output_file}"
|
||||||
|
|
||||||
|
with open(output_file, "r") as f:
|
||||||
|
output_data = f.read()
|
||||||
|
for test_string in test_vector.must_include:
|
||||||
|
assert test_string in output_data
|
||||||
|
for test_string in test_vector.must_not_include:
|
||||||
|
assert test_string not in output_data
|
||||||
|
|
||||||
|
os.remove(output_file)
|
||||||
|
assert not os.path.exists(output_file), f"Output file not deleted: {output_file}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", CLI_TEST_VECTORS)
|
||||||
|
def test_input_from_stdin_without_hints(shared_tmp_dir, test_vector) -> None:
|
||||||
|
"""Test that the CLI readds from stdin correctly."""
|
||||||
|
|
||||||
|
test_input = b""
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, test_vector.filename), "rb") as stream:
|
||||||
|
test_input = stream.read()
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"markitdown",
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename),
|
||||||
|
],
|
||||||
|
input=test_input,
|
||||||
|
capture_output=True,
|
||||||
|
text=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = result.stdout.decode(locale.getpreferredencoding())
|
||||||
|
assert (
|
||||||
|
result.returncode == 0
|
||||||
|
), f"CLI exited with error: {result.stderr.decode('utf-8')}"
|
||||||
|
for test_string in test_vector.must_include:
|
||||||
|
assert test_string in stdout
|
||||||
|
for test_string in test_vector.must_not_include:
|
||||||
|
assert test_string not in stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_remote,
|
||||||
|
reason="do not run tests that query external urls",
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("test_vector", CLI_TEST_VECTORS)
|
||||||
|
def test_convert_url(shared_tmp_dir, test_vector):
|
||||||
|
"""Test the conversion of a stream with no stream info."""
|
||||||
|
# Note: tmp_dir is not used here, but is needed to match the signature
|
||||||
|
|
||||||
|
time.sleep(1) # Ensure we don't hit rate limits
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "markitdown", TEST_FILES_URL + "/" + test_vector.filename],
|
||||||
|
capture_output=True,
|
||||||
|
text=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout = result.stdout.decode(locale.getpreferredencoding())
|
||||||
|
assert result.returncode == 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
for test_string in test_vector.must_include:
|
||||||
|
assert test_string in stdout
|
||||||
|
for test_string in test_vector.must_not_include:
|
||||||
|
assert test_string not in stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", DATA_URI_TEST_VECTORS)
|
||||||
|
def test_output_to_file_with_data_uris(shared_tmp_dir, test_vector) -> None:
|
||||||
|
"""Test CLI functionality when keep_data_uris is enabled"""
|
||||||
|
|
||||||
|
output_file = os.path.join(shared_tmp_dir, test_vector.filename + ".output")
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"python",
|
||||||
|
"-m",
|
||||||
|
"markitdown",
|
||||||
|
"--keep-data-uris",
|
||||||
|
"-o",
|
||||||
|
output_file,
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, f"CLI exited with error: {result.stderr}"
|
||||||
|
assert os.path.exists(output_file), f"Output file not created: {output_file}"
|
||||||
|
|
||||||
|
with open(output_file, "r") as f:
|
||||||
|
output_data = f.read()
|
||||||
|
for test_string in test_vector.must_include:
|
||||||
|
assert test_string in output_data
|
||||||
|
for test_string in test_vector.must_not_include:
|
||||||
|
assert test_string not in output_data
|
||||||
|
|
||||||
|
os.remove(output_file)
|
||||||
|
assert not os.path.exists(output_file), f"Output file not deleted: {output_file}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
"""Runs this file's tests from the command line."""
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# General tests
|
||||||
|
for test_function in [
|
||||||
|
test_output_to_stdout,
|
||||||
|
test_output_to_file,
|
||||||
|
test_input_from_stdin_without_hints,
|
||||||
|
test_convert_url,
|
||||||
|
]:
|
||||||
|
for test_vector in CLI_TEST_VECTORS:
|
||||||
|
print(
|
||||||
|
f"Running {test_function.__name__} on {test_vector.filename}...",
|
||||||
|
end="",
|
||||||
|
)
|
||||||
|
test_function(tmp_dir, test_vector)
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
# Data URI tests
|
||||||
|
for test_function in [
|
||||||
|
test_output_to_file_with_data_uris,
|
||||||
|
]:
|
||||||
|
for test_vector in DATA_URI_TEST_VECTORS:
|
||||||
|
print(
|
||||||
|
f"Running {test_function.__name__} on {test_vector.filename}...",
|
||||||
|
end="",
|
||||||
|
)
|
||||||
|
test_function(tmp_dir, test_vector)
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
print("All tests passed!")
|
||||||
26
packages/markitdown/tests/test_docintel_html.py
Normal file
26
packages/markitdown/tests/test_docintel_html.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import io
|
||||||
|
from markitdown.converters._doc_intel_converter import (
|
||||||
|
DocumentIntelligenceConverter,
|
||||||
|
DocumentIntelligenceFileType,
|
||||||
|
)
|
||||||
|
from markitdown._stream_info import StreamInfo
|
||||||
|
|
||||||
|
|
||||||
|
def _make_converter(file_types):
|
||||||
|
conv = DocumentIntelligenceConverter.__new__(DocumentIntelligenceConverter)
|
||||||
|
conv._file_types = file_types
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
def test_docintel_accepts_html_extension():
|
||||||
|
conv = _make_converter([DocumentIntelligenceFileType.HTML])
|
||||||
|
stream_info = StreamInfo(mimetype=None, extension=".html")
|
||||||
|
assert conv.accepts(io.BytesIO(b""), stream_info)
|
||||||
|
|
||||||
|
|
||||||
|
def test_docintel_accepts_html_mimetype():
|
||||||
|
conv = _make_converter([DocumentIntelligenceFileType.HTML])
|
||||||
|
stream_info = StreamInfo(mimetype="text/html", extension=None)
|
||||||
|
assert conv.accepts(io.BytesIO(b""), stream_info)
|
||||||
|
stream_info = StreamInfo(mimetype="application/xhtml+xml", extension=None)
|
||||||
|
assert conv.accepts(io.BytesIO(b""), stream_info)
|
||||||
BIN
packages/markitdown/tests/test_files/equations.docx
vendored
Normal file
BIN
packages/markitdown/tests/test_files/equations.docx
vendored
Normal file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/random.bin
vendored
Normal file
BIN
packages/markitdown/tests/test_files/random.bin
vendored
Normal file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.docx
vendored
Executable file
BIN
packages/markitdown/tests/test_files/test.docx
vendored
Executable file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.epub
vendored
Normal file
BIN
packages/markitdown/tests/test_files/test.epub
vendored
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 463 KiB |
BIN
packages/markitdown/tests/test_files/test.m4a
vendored
Executable file
BIN
packages/markitdown/tests/test_files/test.m4a
vendored
Executable file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.mp3
vendored
Normal file
BIN
packages/markitdown/tests/test_files/test.mp3
vendored
Normal file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.pdf
vendored
Normal file
BIN
packages/markitdown/tests/test_files/test.pdf
vendored
Normal file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.pptx
vendored
Normal file
BIN
packages/markitdown/tests/test_files/test.pptx
vendored
Normal file
Binary file not shown.
BIN
packages/markitdown/tests/test_files/test.wav
vendored
Normal file
BIN
packages/markitdown/tests/test_files/test.wav
vendored
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
89
packages/markitdown/tests/test_files/test_notebook.ipynb
vendored
Normal file
89
packages/markitdown/tests/test_files/test_notebook.ipynb
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "0f61db80",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Test Notebook"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 11,
|
||||||
|
"id": "3f2a5bbd",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"markitdown\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"print(\"markitdown\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "9b9c0468",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Code Cell Below"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 10,
|
||||||
|
"id": "37d8088a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"42\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"# comment in code\n",
|
||||||
|
"print(42)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2e3177bd",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"End\n",
|
||||||
|
"\n",
|
||||||
|
"---"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "Python 3",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.12.8"
|
||||||
|
},
|
||||||
|
"title": "Test Notebook Title"
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
462
packages/markitdown/tests/test_module_misc.py
Normal file
462
packages/markitdown/tests/test_module_misc.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
#!/usr/bin/env python3 -m pytest
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from markitdown._uri_utils import parse_data_uri, file_uri_to_path
|
||||||
|
|
||||||
|
from markitdown import (
|
||||||
|
MarkItDown,
|
||||||
|
UnsupportedFormatException,
|
||||||
|
FileConversionException,
|
||||||
|
StreamInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
# This file contains module tests that are not directly tested by the FileTestVectors.
|
||||||
|
# This includes things like helper functions and runtime conversion options
|
||||||
|
# (e.g., LLM clients, exiftool path, transcription services, etc.)
|
||||||
|
|
||||||
|
skip_remote = (
|
||||||
|
True if os.environ.get("GITHUB_ACTIONS") else False
|
||||||
|
) # Don't run these tests in CI
|
||||||
|
|
||||||
|
|
||||||
|
# Don't run the llm tests without a key and the client library
|
||||||
|
skip_llm = False if os.environ.get("OPENAI_API_KEY") else True
|
||||||
|
try:
|
||||||
|
import openai
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
skip_llm = True
|
||||||
|
|
||||||
|
# Skip exiftool tests if not installed
|
||||||
|
skip_exiftool = shutil.which("exiftool") is None
|
||||||
|
|
||||||
|
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "test_files")
|
||||||
|
|
||||||
|
JPG_TEST_EXIFTOOL = {
|
||||||
|
"Author": "AutoGen Authors",
|
||||||
|
"Title": "AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"Description": "AutoGen enables diverse LLM-based applications",
|
||||||
|
"ImageSize": "1615x1967",
|
||||||
|
"DateTimeOriginal": "2024:03:14 22:10:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
MP3_TEST_EXIFTOOL = {
|
||||||
|
"Title": "f67a499e-a7d0-4ca3-a49b-358bd934ae3e",
|
||||||
|
"Artist": "Artist Name Test String",
|
||||||
|
"Album": "Album Name Test String",
|
||||||
|
"SampleRate": "48000",
|
||||||
|
}
|
||||||
|
|
||||||
|
PDF_TEST_URL = "https://arxiv.org/pdf/2308.08155v2.pdf"
|
||||||
|
PDF_TEST_STRINGS = [
|
||||||
|
"While there is contemporaneous exploration of multi-agent approaches"
|
||||||
|
]
|
||||||
|
|
||||||
|
YOUTUBE_TEST_URL = "https://www.youtube.com/watch?v=V2qZ_lgxTzg"
|
||||||
|
YOUTUBE_TEST_STRINGS = [
|
||||||
|
"## AutoGen FULL Tutorial with Python (Step-By-Step)",
|
||||||
|
"This is an intermediate tutorial for installing and using AutoGen locally",
|
||||||
|
"PT15M4S",
|
||||||
|
"the model we're going to be using today is GPT 3.5 turbo", # From the transcript
|
||||||
|
]
|
||||||
|
|
||||||
|
DOCX_COMMENT_TEST_STRINGS = [
|
||||||
|
"314b0a30-5b04-470b-b9f7-eed2c2bec74a",
|
||||||
|
"49e168b7-d2ae-407f-a055-2167576f39a1",
|
||||||
|
"## d666f1f7-46cb-42bd-9a39-9a39cf2a509f",
|
||||||
|
"# Abstract",
|
||||||
|
"# Introduction",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"This is a test comment. 12df-321a",
|
||||||
|
"Yet another comment in the doc. 55yiyi-asd09",
|
||||||
|
]
|
||||||
|
|
||||||
|
BLOG_TEST_URL = "https://microsoft.github.io/autogen/blog/2023/04/21/LLM-tuning-math"
|
||||||
|
BLOG_TEST_STRINGS = [
|
||||||
|
"Large language models (LLMs) are powerful tools that can generate natural language texts for various applications, such as chatbots, summarization, translation, and more. GPT-4 is currently the state of the art LLM in the world. Is model selection irrelevant? What about inference parameters?",
|
||||||
|
"an example where high cost can easily prevent a generic complex",
|
||||||
|
]
|
||||||
|
|
||||||
|
LLM_TEST_STRINGS = [
|
||||||
|
"5bda1dd6",
|
||||||
|
]
|
||||||
|
|
||||||
|
PPTX_TEST_STRINGS = [
|
||||||
|
"2cdda5c8-e50e-4db4-b5f0-9722a649f455",
|
||||||
|
"04191ea8-5c73-4215-a1d3-1cfb43aaaf12",
|
||||||
|
"44bf7d06-5e7a-4a40-a2e1-a2e42ef28c8a",
|
||||||
|
"1b92870d-e3b5-4e65-8153-919f4ff45592",
|
||||||
|
"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation",
|
||||||
|
"a3f6004b-6f4f-4ea8-bee3-3741f4dc385f", # chart title
|
||||||
|
"2003", # chart value
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper Functions ---
|
||||||
|
def validate_strings(result, expected_strings, exclude_strings=None):
|
||||||
|
"""Validate presence or absence of specific strings."""
|
||||||
|
text_content = result.text_content.replace("\\", "")
|
||||||
|
for string in expected_strings:
|
||||||
|
assert string in text_content
|
||||||
|
if exclude_strings:
|
||||||
|
for string in exclude_strings:
|
||||||
|
assert string not in text_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_info_operations() -> None:
|
||||||
|
"""Test operations performed on StreamInfo objects."""
|
||||||
|
|
||||||
|
stream_info_original = StreamInfo(
|
||||||
|
mimetype="mimetype.1",
|
||||||
|
extension="extension.1",
|
||||||
|
charset="charset.1",
|
||||||
|
filename="filename.1",
|
||||||
|
local_path="local_path.1",
|
||||||
|
url="url.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check updating all attributes by keyword
|
||||||
|
keywords = ["mimetype", "extension", "charset", "filename", "local_path", "url"]
|
||||||
|
for keyword in keywords:
|
||||||
|
updated_stream_info = stream_info_original.copy_and_update(
|
||||||
|
**{keyword: f"{keyword}.2"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure the targted attribute is updated
|
||||||
|
assert getattr(updated_stream_info, keyword) == f"{keyword}.2"
|
||||||
|
|
||||||
|
# Make sure the other attributes are unchanged
|
||||||
|
for k in keywords:
|
||||||
|
if k != keyword:
|
||||||
|
assert getattr(stream_info_original, k) == getattr(
|
||||||
|
updated_stream_info, k
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check updating all attributes by passing a new StreamInfo object
|
||||||
|
keywords = ["mimetype", "extension", "charset", "filename", "local_path", "url"]
|
||||||
|
for keyword in keywords:
|
||||||
|
updated_stream_info = stream_info_original.copy_and_update(
|
||||||
|
StreamInfo(**{keyword: f"{keyword}.2"})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure the targted attribute is updated
|
||||||
|
assert getattr(updated_stream_info, keyword) == f"{keyword}.2"
|
||||||
|
|
||||||
|
# Make sure the other attributes are unchanged
|
||||||
|
for k in keywords:
|
||||||
|
if k != keyword:
|
||||||
|
assert getattr(stream_info_original, k) == getattr(
|
||||||
|
updated_stream_info, k
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check mixing and matching
|
||||||
|
updated_stream_info = stream_info_original.copy_and_update(
|
||||||
|
StreamInfo(extension="extension.2", filename="filename.2"),
|
||||||
|
mimetype="mimetype.3",
|
||||||
|
charset="charset.3",
|
||||||
|
)
|
||||||
|
assert updated_stream_info.extension == "extension.2"
|
||||||
|
assert updated_stream_info.filename == "filename.2"
|
||||||
|
assert updated_stream_info.mimetype == "mimetype.3"
|
||||||
|
assert updated_stream_info.charset == "charset.3"
|
||||||
|
assert updated_stream_info.local_path == "local_path.1"
|
||||||
|
assert updated_stream_info.url == "url.1"
|
||||||
|
|
||||||
|
# Check multiple StreamInfo objects
|
||||||
|
updated_stream_info = stream_info_original.copy_and_update(
|
||||||
|
StreamInfo(extension="extension.4", filename="filename.5"),
|
||||||
|
StreamInfo(mimetype="mimetype.6", charset="charset.7"),
|
||||||
|
)
|
||||||
|
assert updated_stream_info.extension == "extension.4"
|
||||||
|
assert updated_stream_info.filename == "filename.5"
|
||||||
|
assert updated_stream_info.mimetype == "mimetype.6"
|
||||||
|
assert updated_stream_info.charset == "charset.7"
|
||||||
|
assert updated_stream_info.local_path == "local_path.1"
|
||||||
|
assert updated_stream_info.url == "url.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_uris() -> None:
|
||||||
|
# Test basic parsing of data URIs
|
||||||
|
data_uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type == "text/plain"
|
||||||
|
assert len(attributes) == 0
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
data_uri = "data:base64,SGVsbG8sIFdvcmxkIQ=="
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type is None
|
||||||
|
assert len(attributes) == 0
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
data_uri = "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ=="
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type == "text/plain"
|
||||||
|
assert len(attributes) == 1
|
||||||
|
assert attributes["charset"] == "utf-8"
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
data_uri = "data:,Hello%2C%20World%21"
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type is None
|
||||||
|
assert len(attributes) == 0
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
data_uri = "data:text/plain,Hello%2C%20World%21"
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type == "text/plain"
|
||||||
|
assert len(attributes) == 0
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
data_uri = "data:text/plain;charset=utf-8,Hello%2C%20World%21"
|
||||||
|
mime_type, attributes, data = parse_data_uri(data_uri)
|
||||||
|
assert mime_type == "text/plain"
|
||||||
|
assert len(attributes) == 1
|
||||||
|
assert attributes["charset"] == "utf-8"
|
||||||
|
assert data == b"Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_uris() -> None:
|
||||||
|
# Test file URI with an empty host
|
||||||
|
file_uri = "file:///path/to/file.txt"
|
||||||
|
netloc, path = file_uri_to_path(file_uri)
|
||||||
|
assert netloc is None
|
||||||
|
assert path == "/path/to/file.txt"
|
||||||
|
|
||||||
|
# Test file URI with no host
|
||||||
|
file_uri = "file:/path/to/file.txt"
|
||||||
|
netloc, path = file_uri_to_path(file_uri)
|
||||||
|
assert netloc is None
|
||||||
|
assert path == "/path/to/file.txt"
|
||||||
|
|
||||||
|
# Test file URI with localhost
|
||||||
|
file_uri = "file://localhost/path/to/file.txt"
|
||||||
|
netloc, path = file_uri_to_path(file_uri)
|
||||||
|
assert netloc == "localhost"
|
||||||
|
assert path == "/path/to/file.txt"
|
||||||
|
|
||||||
|
# Test file URI with query parameters
|
||||||
|
file_uri = "file:///path/to/file.txt?param=value"
|
||||||
|
netloc, path = file_uri_to_path(file_uri)
|
||||||
|
assert netloc is None
|
||||||
|
assert path == "/path/to/file.txt"
|
||||||
|
|
||||||
|
# Test file URI with fragment
|
||||||
|
file_uri = "file:///path/to/file.txt#fragment"
|
||||||
|
netloc, path = file_uri_to_path(file_uri)
|
||||||
|
assert netloc is None
|
||||||
|
assert path == "/path/to/file.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_comments() -> None:
|
||||||
|
# Test DOCX processing, with comments and setting style_map on init
|
||||||
|
markitdown_with_style_map = MarkItDown(style_map="comment-reference => ")
|
||||||
|
result = markitdown_with_style_map.convert(
|
||||||
|
os.path.join(TEST_FILES_DIR, "test_with_comment.docx")
|
||||||
|
)
|
||||||
|
validate_strings(result, DOCX_COMMENT_TEST_STRINGS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_equations() -> None:
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
docx_file = os.path.join(TEST_FILES_DIR, "equations.docx")
|
||||||
|
result = markitdown.convert(docx_file)
|
||||||
|
|
||||||
|
# Check for inline equation m=1 (wrapped with single $) is present
|
||||||
|
assert "$m=1$" in result.text_content, "Inline equation $m=1$ not found"
|
||||||
|
|
||||||
|
# Find block equations wrapped with double $$ and check if they are present
|
||||||
|
block_equations = re.findall(r"\$\$(.+?)\$\$", result.text_content)
|
||||||
|
assert block_equations, "No block equations found in the document."
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_as_strings() -> None:
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
# Test input from a stream
|
||||||
|
input_data = b"<html><body><h1>Test</h1></body></html>"
|
||||||
|
result = markitdown.convert_stream(io.BytesIO(input_data))
|
||||||
|
assert "# Test" in result.text_content
|
||||||
|
|
||||||
|
# Test input with leading blank characters
|
||||||
|
input_data = b" \n\n\n<html><body><h1>Test</h1></body></html>"
|
||||||
|
result = markitdown.convert_stream(io.BytesIO(input_data))
|
||||||
|
assert "# Test" in result.text_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_remote,
|
||||||
|
reason="do not run tests that query external urls",
|
||||||
|
)
|
||||||
|
def test_markitdown_remote() -> None:
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
# By URL
|
||||||
|
result = markitdown.convert(PDF_TEST_URL)
|
||||||
|
for test_string in PDF_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
|
||||||
|
# Youtube
|
||||||
|
result = markitdown.convert(YOUTUBE_TEST_URL)
|
||||||
|
for test_string in YOUTUBE_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_remote,
|
||||||
|
reason="do not run remotely run speech transcription tests",
|
||||||
|
)
|
||||||
|
def test_speech_transcription() -> None:
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
# Test WAV files, MP3 and M4A files
|
||||||
|
for file_name in ["test.wav", "test.mp3", "test.m4a"]:
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, file_name))
|
||||||
|
result_lower = result.text_content.lower()
|
||||||
|
assert (
|
||||||
|
("1" in result_lower or "one" in result_lower)
|
||||||
|
and ("2" in result_lower or "two" in result_lower)
|
||||||
|
and ("3" in result_lower or "three" in result_lower)
|
||||||
|
and ("4" in result_lower or "four" in result_lower)
|
||||||
|
and ("5" in result_lower or "five" in result_lower)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exceptions() -> None:
|
||||||
|
# Check that an exception is raised when trying to convert an unsupported format
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
with pytest.raises(UnsupportedFormatException):
|
||||||
|
markitdown.convert(os.path.join(TEST_FILES_DIR, "random.bin"))
|
||||||
|
|
||||||
|
# Check that an exception is raised when trying to convert a file that is corrupted
|
||||||
|
with pytest.raises(FileConversionException) as exc_info:
|
||||||
|
markitdown.convert(
|
||||||
|
os.path.join(TEST_FILES_DIR, "random.bin"), file_extension=".pptx"
|
||||||
|
)
|
||||||
|
assert len(exc_info.value.attempts) == 1
|
||||||
|
assert type(exc_info.value.attempts[0].converter).__name__ == "PptxConverter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_exiftool,
|
||||||
|
reason="do not run if exiftool is not installed",
|
||||||
|
)
|
||||||
|
def test_markitdown_exiftool() -> None:
|
||||||
|
which_exiftool = shutil.which("exiftool")
|
||||||
|
assert which_exiftool is not None
|
||||||
|
|
||||||
|
# Test explicitly setting the location of exiftool
|
||||||
|
markitdown = MarkItDown(exiftool_path=which_exiftool)
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test.jpg"))
|
||||||
|
for key in JPG_TEST_EXIFTOOL:
|
||||||
|
target = f"{key}: {JPG_TEST_EXIFTOOL[key]}"
|
||||||
|
assert target in result.text_content
|
||||||
|
|
||||||
|
# Test setting the exiftool path through an environment variable
|
||||||
|
os.environ["EXIFTOOL_PATH"] = which_exiftool
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test.jpg"))
|
||||||
|
for key in JPG_TEST_EXIFTOOL:
|
||||||
|
target = f"{key}: {JPG_TEST_EXIFTOOL[key]}"
|
||||||
|
assert target in result.text_content
|
||||||
|
|
||||||
|
# Test some other media types
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test.mp3"))
|
||||||
|
for key in MP3_TEST_EXIFTOOL:
|
||||||
|
target = f"{key}: {MP3_TEST_EXIFTOOL[key]}"
|
||||||
|
assert target in result.text_content
|
||||||
|
|
||||||
|
|
||||||
|
def test_markitdown_llm_parameters() -> None:
|
||||||
|
"""Test that LLM parameters are correctly passed to the client."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [
|
||||||
|
MagicMock(
|
||||||
|
message=MagicMock(
|
||||||
|
content="Test caption with red circle and blue square 5bda1dd6"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
mock_client.chat.completions.create.return_value = mock_response
|
||||||
|
|
||||||
|
test_prompt = "You are a professional test prompt."
|
||||||
|
markitdown = MarkItDown(
|
||||||
|
llm_client=mock_client, llm_model="gpt-4o", llm_prompt=test_prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test image file
|
||||||
|
markitdown.convert(os.path.join(TEST_FILES_DIR, "test_llm.jpg"))
|
||||||
|
|
||||||
|
# Verify the prompt was passed to the OpenAI API
|
||||||
|
assert mock_client.chat.completions.create.called
|
||||||
|
call_args = mock_client.chat.completions.create.call_args
|
||||||
|
messages = call_args[1]["messages"]
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert messages[0]["content"][0]["text"] == test_prompt
|
||||||
|
|
||||||
|
# Reset the mock for the next test
|
||||||
|
mock_client.chat.completions.create.reset_mock()
|
||||||
|
|
||||||
|
# TODO: may only use one test after the llm caption method duplicate has been removed:
|
||||||
|
# https://github.com/microsoft/markitdown/pull/1254
|
||||||
|
# Test PPTX file
|
||||||
|
markitdown.convert(os.path.join(TEST_FILES_DIR, "test.pptx"))
|
||||||
|
|
||||||
|
# Verify the prompt was passed to the OpenAI API for PPTX images too
|
||||||
|
assert mock_client.chat.completions.create.called
|
||||||
|
call_args = mock_client.chat.completions.create.call_args
|
||||||
|
messages = call_args[1]["messages"]
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert messages[0]["content"][0]["text"] == test_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_llm,
|
||||||
|
reason="do not run llm tests without a key",
|
||||||
|
)
|
||||||
|
def test_markitdown_llm() -> None:
|
||||||
|
client = openai.OpenAI()
|
||||||
|
markitdown = MarkItDown(llm_client=client, llm_model="gpt-4o")
|
||||||
|
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test_llm.jpg"))
|
||||||
|
for test_string in LLM_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
|
||||||
|
# This is not super precise. It would also accept "red square", "blue circle",
|
||||||
|
# "the square is not blue", etc. But it's sufficient for this test.
|
||||||
|
for test_string in ["red", "circle", "blue", "square"]:
|
||||||
|
assert test_string in result.text_content.lower()
|
||||||
|
|
||||||
|
# Images embedded in PPTX files
|
||||||
|
result = markitdown.convert(os.path.join(TEST_FILES_DIR, "test.pptx"))
|
||||||
|
# LLM Captions are included
|
||||||
|
for test_string in LLM_TEST_STRINGS:
|
||||||
|
assert test_string in result.text_content
|
||||||
|
# Standard alt text is included
|
||||||
|
validate_strings(result, PPTX_TEST_STRINGS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
"""Runs this file's tests from the command line."""
|
||||||
|
for test in [
|
||||||
|
test_stream_info_operations,
|
||||||
|
test_data_uris,
|
||||||
|
test_file_uris,
|
||||||
|
test_docx_comments,
|
||||||
|
test_input_as_strings,
|
||||||
|
test_markitdown_remote,
|
||||||
|
test_speech_transcription,
|
||||||
|
test_exceptions,
|
||||||
|
test_markitdown_exiftool,
|
||||||
|
test_markitdown_llm_parameters,
|
||||||
|
test_markitdown_llm,
|
||||||
|
]:
|
||||||
|
print(f"Running {test.__name__}...", end="")
|
||||||
|
test()
|
||||||
|
print("OK")
|
||||||
|
print("All tests passed!")
|
||||||
234
packages/markitdown/tests/test_module_vectors.py
Normal file
234
packages/markitdown/tests/test_module_vectors.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env python3 -m pytest
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from _test_vectors import GENERAL_TEST_VECTORS, DATA_URI_TEST_VECTORS
|
||||||
|
else:
|
||||||
|
from ._test_vectors import GENERAL_TEST_VECTORS, DATA_URI_TEST_VECTORS
|
||||||
|
|
||||||
|
from markitdown import (
|
||||||
|
MarkItDown,
|
||||||
|
StreamInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
skip_remote = (
|
||||||
|
True if os.environ.get("GITHUB_ACTIONS") else False
|
||||||
|
) # Don't run these tests in CI
|
||||||
|
|
||||||
|
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "test_files")
|
||||||
|
TEST_FILES_URL = "https://raw.githubusercontent.com/microsoft/markitdown/refs/heads/main/packages/markitdown/tests/test_files"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_guess_stream_info(test_vector):
|
||||||
|
"""Test the ability to guess stream info."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
local_path = os.path.join(TEST_FILES_DIR, test_vector.filename)
|
||||||
|
expected_extension = os.path.splitext(test_vector.filename)[1]
|
||||||
|
|
||||||
|
with open(local_path, "rb") as stream:
|
||||||
|
guesses = markitdown._get_stream_info_guesses(
|
||||||
|
stream,
|
||||||
|
base_guess=StreamInfo(
|
||||||
|
filename=os.path.basename(test_vector.filename),
|
||||||
|
local_path=local_path,
|
||||||
|
extension=expected_extension,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# For some limited exceptions, we can't guarantee the exact
|
||||||
|
# mimetype or extension, so we'll special-case them here.
|
||||||
|
if test_vector.filename in [
|
||||||
|
"test_outlook_msg.msg",
|
||||||
|
]:
|
||||||
|
return
|
||||||
|
|
||||||
|
assert guesses[0].mimetype == test_vector.mimetype
|
||||||
|
assert guesses[0].extension == expected_extension
|
||||||
|
assert guesses[0].charset == test_vector.charset
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_local(test_vector):
|
||||||
|
"""Test the conversion of a local file."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
result = markitdown.convert(
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename), url=test_vector.url
|
||||||
|
)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_stream_with_hints(test_vector):
|
||||||
|
"""Test the conversion of a stream with full stream info."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
stream_info = StreamInfo(
|
||||||
|
extension=os.path.splitext(test_vector.filename)[1],
|
||||||
|
mimetype=test_vector.mimetype,
|
||||||
|
charset=test_vector.charset,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, test_vector.filename), "rb") as stream:
|
||||||
|
result = markitdown.convert(
|
||||||
|
stream, stream_info=stream_info, url=test_vector.url
|
||||||
|
)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_stream_without_hints(test_vector):
|
||||||
|
"""Test the conversion of a stream with no stream info."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, test_vector.filename), "rb") as stream:
|
||||||
|
result = markitdown.convert(stream, url=test_vector.url)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
skip_remote,
|
||||||
|
reason="do not run tests that query external urls",
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_http_uri(test_vector):
|
||||||
|
"""Test the conversion of an HTTP:// or HTTPS:// URI."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
time.sleep(1) # Ensure we don't hit rate limits
|
||||||
|
|
||||||
|
result = markitdown.convert(
|
||||||
|
TEST_FILES_URL + "/" + test_vector.filename,
|
||||||
|
url=test_vector.url, # Mock where this file would be found
|
||||||
|
)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_file_uri(test_vector):
|
||||||
|
"""Test the conversion of a file:// URI."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
result = markitdown.convert(
|
||||||
|
Path(os.path.join(TEST_FILES_DIR, test_vector.filename)).as_uri(),
|
||||||
|
url=test_vector.url,
|
||||||
|
)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", GENERAL_TEST_VECTORS)
|
||||||
|
def test_convert_data_uri(test_vector):
|
||||||
|
"""Test the conversion of a data URI."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
data = ""
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, test_vector.filename), "rb") as stream:
|
||||||
|
data = base64.b64encode(stream.read()).decode("utf-8")
|
||||||
|
mimetype = test_vector.mimetype
|
||||||
|
data_uri = f"data:{mimetype};base64,{data}"
|
||||||
|
|
||||||
|
result = markitdown.convert(
|
||||||
|
data_uri,
|
||||||
|
url=test_vector.url,
|
||||||
|
)
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", DATA_URI_TEST_VECTORS)
|
||||||
|
def test_convert_keep_data_uris(test_vector):
|
||||||
|
"""Test API functionality when keep_data_uris is enabled"""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
# Test local file conversion
|
||||||
|
result = markitdown.convert(
|
||||||
|
os.path.join(TEST_FILES_DIR, test_vector.filename),
|
||||||
|
keep_data_uris=True,
|
||||||
|
url=test_vector.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_vector", DATA_URI_TEST_VECTORS)
|
||||||
|
def test_convert_stream_keep_data_uris(test_vector):
|
||||||
|
"""Test the conversion of a stream with no stream info."""
|
||||||
|
markitdown = MarkItDown()
|
||||||
|
|
||||||
|
stream_info = StreamInfo(
|
||||||
|
extension=os.path.splitext(test_vector.filename)[1],
|
||||||
|
mimetype=test_vector.mimetype,
|
||||||
|
charset=test_vector.charset,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.path.join(TEST_FILES_DIR, test_vector.filename), "rb") as stream:
|
||||||
|
result = markitdown.convert(
|
||||||
|
stream, stream_info=stream_info, keep_data_uris=True, url=test_vector.url
|
||||||
|
)
|
||||||
|
|
||||||
|
for string in test_vector.must_include:
|
||||||
|
assert string in result.markdown
|
||||||
|
for string in test_vector.must_not_include:
|
||||||
|
assert string not in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
"""Runs this file's tests from the command line."""
|
||||||
|
|
||||||
|
# General tests
|
||||||
|
for test_function in [
|
||||||
|
test_guess_stream_info,
|
||||||
|
test_convert_local,
|
||||||
|
test_convert_stream_with_hints,
|
||||||
|
test_convert_stream_without_hints,
|
||||||
|
test_convert_http_uri,
|
||||||
|
test_convert_file_uri,
|
||||||
|
test_convert_data_uri,
|
||||||
|
]:
|
||||||
|
for test_vector in GENERAL_TEST_VECTORS:
|
||||||
|
print(
|
||||||
|
f"Running {test_function.__name__} on {test_vector.filename}...", end=""
|
||||||
|
)
|
||||||
|
test_function(test_vector)
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
# Data URI tests
|
||||||
|
for test_function in [
|
||||||
|
test_convert_keep_data_uris,
|
||||||
|
test_convert_stream_keep_data_uris,
|
||||||
|
]:
|
||||||
|
for test_vector in DATA_URI_TEST_VECTORS:
|
||||||
|
print(
|
||||||
|
f"Running {test_function.__name__} on {test_vector.filename}...", end=""
|
||||||
|
)
|
||||||
|
test_function(test_vector)
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
print("All tests passed!")
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
from ._markitdown import MarkItDown, FileConversionException, UnsupportedFormatException
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"MarkItDown",
|
|
||||||
"FileConversionException",
|
|
||||||
"UnsupportedFormatException",
|
|
||||||
]
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2024-present Adam Fourney <adamfo@microsoft.com>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: MIT
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from textwrap import dedent
|
|
||||||
from .__about__ import __version__
|
|
||||||
from ._markitdown import MarkItDown, DocumentConverterResult
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Convert various file formats to markdown.",
|
|
||||||
prog="markitdown",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
usage=dedent(
|
|
||||||
"""
|
|
||||||
SYNTAX:
|
|
||||||
|
|
||||||
markitdown <OPTIONAL: FILENAME>
|
|
||||||
If FILENAME is empty, markitdown reads from stdin.
|
|
||||||
|
|
||||||
EXAMPLE:
|
|
||||||
|
|
||||||
markitdown example.pdf
|
|
||||||
|
|
||||||
OR
|
|
||||||
|
|
||||||
cat example.pdf | markitdown
|
|
||||||
|
|
||||||
OR
|
|
||||||
|
|
||||||
markitdown < example.pdf
|
|
||||||
|
|
||||||
OR to save to a file use
|
|
||||||
|
|
||||||
markitdown example.pdf -o example.md
|
|
||||||
|
|
||||||
OR
|
|
||||||
|
|
||||||
markitdown example.pdf > example.md
|
|
||||||
"""
|
|
||||||
).strip(),
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-v",
|
|
||||||
"--version",
|
|
||||||
action="version",
|
|
||||||
version=f"%(prog)s {__version__}",
|
|
||||||
help="show the version number and exit",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
help="Output file name. If not provided, output is written to stdout.",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-d",
|
|
||||||
"--use-docintel",
|
|
||||||
action="store_true",
|
|
||||||
help="Use Document Intelligence to extract text instead of offline conversion. Requires a valid Document Intelligence Endpoint.",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"-e",
|
|
||||||
"--endpoint",
|
|
||||||
type=str,
|
|
||||||
help="Document Intelligence Endpoint. Required if using Document Intelligence.",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument("filename", nargs="?")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.use_docintel:
|
|
||||||
if args.endpoint is None:
|
|
||||||
raise ValueError(
|
|
||||||
"Document Intelligence Endpoint is required when using Document Intelligence."
|
|
||||||
)
|
|
||||||
elif args.filename is None:
|
|
||||||
raise ValueError("Filename is required when using Document Intelligence.")
|
|
||||||
markitdown = MarkItDown(docintel_endpoint=args.endpoint)
|
|
||||||
else:
|
|
||||||
markitdown = MarkItDown()
|
|
||||||
|
|
||||||
if args.filename is None:
|
|
||||||
result = markitdown.convert_stream(sys.stdin.buffer)
|
|
||||||
else:
|
|
||||||
result = markitdown.convert(args.filename)
|
|
||||||
|
|
||||||
_handle_output(args, result)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_output(args, result: DocumentConverterResult):
|
|
||||||
"""Handle output to stdout or file"""
|
|
||||||
if args.output:
|
|
||||||
with open(args.output, "w", encoding="utf-8") as f:
|
|
||||||
f.write(result.text_content)
|
|
||||||
else:
|
|
||||||
print(result.text_content)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user