Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,9 @@ cython_debug/
src/.DS_Store
.DS_Store
.cursorrules
# Added by code-review-graph
.code-review-graph/
# Claude Code configuration (local only)
.claude/
.mcp.json
CLAUDE.md
2 changes: 2 additions & 0 deletions packages/markitdown/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
[project.optional-dependencies]
all = [
"python-pptx",
"unword>=0.2.2",
"mammoth~=1.11.0",
"pandas",
"openpyxl",
Expand All @@ -51,6 +52,7 @@ all = [
"azure-identity",
]
pptx = ["python-pptx"]
doc = ["unword>=0.2.2"]
docx = ["mammoth~=1.11.0", "lxml"]
xlsx = ["pandas", "openpyxl"]
xls = ["pandas", "xlrd"]
Expand Down
2 changes: 2 additions & 0 deletions packages/markitdown/src/markitdown/_markitdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
IpynbConverter,
BingSerpConverter,
PdfConverter,
DocConverter,
DocxConverter,
XlsxConverter,
XlsConverter,
Expand Down Expand Up @@ -192,6 +193,7 @@ def enable_builtins(self, **kwargs) -> None:
self.register_converter(WikipediaConverter())
self.register_converter(YouTubeConverter())
self.register_converter(BingSerpConverter())
self.register_converter(DocConverter())
self.register_converter(DocxConverter())
self.register_converter(XlsxConverter())
self.register_converter(XlsConverter())
Expand Down
2 changes: 2 additions & 0 deletions packages/markitdown/src/markitdown/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ._ipynb_converter import IpynbConverter
from ._bing_serp_converter import BingSerpConverter
from ._pdf_converter import PdfConverter
from ._doc_converter import DocConverter
from ._docx_converter import DocxConverter
from ._xlsx_converter import XlsxConverter, XlsConverter
from ._pptx_converter import PptxConverter
Expand Down Expand Up @@ -37,6 +38,7 @@
"IpynbConverter",
"BingSerpConverter",
"PdfConverter",
"DocConverter",
"DocxConverter",
"XlsxConverter",
"XlsConverter",
Expand Down
86 changes: 86 additions & 0 deletions packages/markitdown/src/markitdown/converters/_doc_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import sys
import io
from warnings import warn

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 unword

except ImportError:
# Preserve the error and stack trace for later
_dependency_exc_info = sys.exc_info()


ACCEPTED_MIME_TYPE_PREFIXES = [
"application/msword",
]

ACCEPTED_FILE_EXTENSIONS = [".doc"]


class DocConverter(DocumentConverter):
"""
Converts DOC files (Microsoft Word 97-2003) to Markdown. Style information and basic formatting are preserved where possible.
"""

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:
# Check the dependencies
if _dependency_exc_info is not None:
raise MissingDependencyException(
MISSING_DEPENDENCY_MESSAGE.format(
converter=type(self).__name__,
extension=".doc",
feature="doc",
)
) from _dependency_exc_info[
1
].with_traceback( # type: ignore[union-attr]
_dependency_exc_info[2]
)

# Read the file content
file_content = file_stream.read()

try:
# Parse the .doc file using unword
doc = unword.parse_doc(file_content)
# Extract body text from the parsed document
text = doc.body_text
except Exception as e:
raise ValueError(f"Failed to parse DOC file: {str(e)}") from e

return DocumentConverterResult(markdown=text)
48 changes: 48 additions & 0 deletions packages/markitdown/tests/test_doc_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Tests for DocConverter (.doc file support)."""

import io
from unittest.mock import MagicMock, patch

import pytest

from markitdown.converters._doc_converter import DocConverter
from markitdown._stream_info import StreamInfo


def test_doc_converter_accepts_doc_extension():
"""Test that DocConverter accepts .doc files."""
converter = DocConverter()
stream_info = StreamInfo(extension=".doc")
assert converter.accepts(io.BytesIO(), stream_info) is True


def test_doc_converter_accepts_doc_mimetype():
"""Test that DocConverter accepts application/msword MIME type."""
converter = DocConverter()
stream_info = StreamInfo(mimetype="application/msword")
assert converter.accepts(io.BytesIO(), stream_info) is True


def test_doc_converter_rejects_other_extensions():
"""Test that DocConverter rejects non-.doc files."""
converter = DocConverter()
stream_info = StreamInfo(extension=".docx")
assert converter.accepts(io.BytesIO(), stream_info) is False


@patch("markitdown.converters._doc_converter.unword")
def test_doc_converter_convert(mock_unword):
"""Test that DocConverter converts .doc files correctly."""
# Mock the unword.parse_doc function
mock_doc = MagicMock()
mock_doc.body_text = "This is test content from a .doc file"
mock_unword.parse_doc.return_value = mock_doc

converter = DocConverter()
stream_info = StreamInfo(extension=".doc")
file_content = b"fake .doc file content"

result = converter.convert(io.BytesIO(file_content), stream_info)

assert result.markdown == "This is test content from a .doc file"
mock_unword.parse_doc.assert_called_once_with(file_content)