Repository: Quansight/ragna (python)

Issue:
[ENH] - Add support for .doc / .docx
### Feature description

Word documents are everywhere in the corporate world. We should support them out of the box. There are multiple Python packages out there that provide this functionality. We should do a light comparison of them to get a clearer picture. Optimally, we find a package that can give us page information. Not sure if that is possible in these data formats though.

### Value and/or benefit

Users with corporate documents will have a lower barrier of entry. Requested in https://github.com/Quansight/ragna/issues/224#issuecomment-1838864057.

### Anything else?

_No response_

Pull request description:
Support using .docx files
@pmeier If you could check out the test I added, let me know if you think that's sufficient.

Resolves #225


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index e4ffa2c..20b5a80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,6 +59,7 @@ all = [
     "lancedb>=0.2",
     "pyarrow",
     "pymupdf>=1.23.6",
+    "python-docx",
     "tiktoken",
 ]
 
@@ -141,6 +142,7 @@ module = [
     "lancedb",
     "param",
     "pyarrow",
+    "docx",
     "sentence_transformers",
 ]
 ignore_missing_imports = true
diff --git a/ragna/core/__init__.py b/ragna/core/__init__.py
index 2bfa7bc..14da1ff 100644
--- a/ragna/core/__init__.py
+++ b/ragna/core/__init__.py
@@ -5,6 +5,7 @@ __all__ = [
     "Document",
     "DocumentHandler",
     "DocumentUploadParameters",
+    "DocxDocumentHandler",
     "EnvVarRequirement",
     "LocalDocument",
     "Message",
@@ -33,6 +34,7 @@ from ._document import (
     Document,
     DocumentHandler,
     DocumentUploadParameters,
+    DocxDocumentHandler,
     LocalDocument,
     Page,
     PdfDocumentHandler,
diff --git a/ragna/core/_document.py b/ragna/core/_document.py
index beb3632..6abf748 100644
--- a/ragna/core/_document.py
+++ b/ragna/core/_document.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import abc
+import io
 import os
 import secrets
 import time
@@ -269,3 +270,34 @@ class PdfDocumentHandler(DocumentHandler):
         ) as document:
             for number, page in enumerate(document, 1):
                 yield Page(text=page.get_text(sort=True), number=number)
+
+
+@DOCUMENT_HANDLERS.load_if_available
+class DocxDocumentHandler(DocumentHandler):
+    """Document handler for `.docx` documents.
+
+    !!! note
+
+        This does *not* extract text from headers or footers.
+
+    !!! info "Package requirements"
+
+        - [`python-docx`](https://github.com/python-openxml/python-docx)
+    """
+
+    @classmethod
+    def requirements(cls) -> list[Requirement]:
+        return [PackageRequirement("python-docx")]
+
+    @classmethod
+    def supported_suffixes(cls) -> list[str]:
+        return [".docx"]
+
+    def extract_pages(self, document: Document) -> Iterator[Page]:
+        import docx
+
+        document_docx = docx.Document(io.BytesIO(document.read()))
+        for paragraph in document_docx.paragraphs:
+            text = paragraph.text
+            if len(text) > 0:
+                yield Page(text=text)


Test patch:
diff --git a/tests/core/test_document.py b/tests/core/test_document.py
new file mode 100644
index 0000000..e2c44ae
--- /dev/null
+++ b/tests/core/test_document.py
@@ -0,0 +1,22 @@
+import docx
+
+from ragna.core import DocxDocumentHandler, LocalDocument
+
+
+def get_docx_document(tmp_path, docx_text):
+    document = docx.Document()
+    document.add_heading(docx_text)
+    document.add_paragraph(docx_text)
+    path = tmp_path / "test_document.docx"
+    document.save(path)
+    return LocalDocument.from_path(path)
+
+
+def test_docx(tmp_path):
+    docx_text = "ragna is neat!"
+    tmp_docx_document = get_docx_document(tmp_path, docx_text)
+    assert isinstance(tmp_docx_document.handler, DocxDocumentHandler)
+    pages = list(tmp_docx_document.extract_pages())
+    assert len(pages) == 2
+    for page in pages:
+        assert page.text == docx_text