From c1c339923f69e4453371d24c2a12289c2a8fcfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Fern=C3=A1ndez?= <67836662+CarlosFerLo@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:58:36 +0200 Subject: [PATCH] feat: add DocxToDocument converter (#7838) * first fucntioning DocxFileToDocument * fix lazy import message * add reno * Add license headder Co-authored-by: Sebastian Husch Lee * change DocxFileToDocument to DocxToDocument * Update library install to the maintained version Co-authored-by: Sebastian Husch Lee * clan try-exvept to only take non haystack errors into account * Add wanring on docstring of component ignoring page brakes, mark test as skip * make warnings lazy evaluations Co-authored-by: Sebastian Husch Lee * make warnings lazy evaluations Co-authored-by: Sebastian Husch Lee * Make warnings lazy evaluated Co-authored-by: Sebastian Husch Lee * Solve f bug * Get more metadata from docx files * add 'python-docx' dependency and docs * Change logging import Co-authored-by: Sebastian Husch Lee * Fix typo Co-authored-by: Sebastian Husch Lee * remake metadata extraction for docx * solve bug regarding _get_docx_metadata method * Update haystack/components/converters/docx.py Co-authored-by: Sebastian Husch Lee * Update haystack/components/converters/docx.py Co-authored-by: Sebastian Husch Lee * Delete unused test --------- Co-authored-by: Sebastian Husch Lee --- docs/pydoc/config/converters_api.yml | 1 + haystack/components/converters/__init__.py | 2 + haystack/components/converters/docx.py | 144 ++++++++++++++++++ pyproject.toml | 3 +- ...ocx-file-to-document-47b603755a00fbe6.yaml | 6 + .../converters/test_docx_file_to_document.py | 63 ++++++++ test/test_files/docx/sample_docx_1.docx | Bin 0 -> 13342 bytes 7 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 haystack/components/converters/docx.py create mode 100644 releasenotes/notes/add-docx-file-to-document-47b603755a00fbe6.yaml create mode 100644 test/components/converters/test_docx_file_to_document.py create mode 100644 test/test_files/docx/sample_docx_1.docx diff --git a/docs/pydoc/config/converters_api.yml b/docs/pydoc/config/converters_api.yml index 16ae5680d..e8ec88c7a 100644 --- a/docs/pydoc/config/converters_api.yml +++ b/docs/pydoc/config/converters_api.yml @@ -13,6 +13,7 @@ loaders: "txt", "output_adapter", "openapi_functions", + "docx" ] ignore_when_discovered: ["__init__"] processors: diff --git a/haystack/components/converters/__init__.py b/haystack/components/converters/__init__.py index 54699c78e..32f8a7b0c 100644 --- a/haystack/components/converters/__init__.py +++ b/haystack/components/converters/__init__.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 from haystack.components.converters.azure import AzureOCRDocumentConverter +from haystack.components.converters.docx import DocxToDocument from haystack.components.converters.html import HTMLToDocument from haystack.components.converters.markdown import MarkdownToDocument from haystack.components.converters.openapi_functions import OpenAPIServiceToFunctions @@ -22,4 +23,5 @@ __all__ = [ "MarkdownToDocument", "OpenAPIServiceToFunctions", "OutputAdapter", + "DocxToDocument", ] diff --git a/haystack/components/converters/docx.py b/haystack/components/converters/docx.py new file mode 100644 index 000000000..d255617e0 --- /dev/null +++ b/haystack/components/converters/docx.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import io +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from haystack import Document, component, logging +from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata +from haystack.dataclasses import ByteStream +from haystack.lazy_imports import LazyImport + +logger = logging.getLogger(__name__) + +with LazyImport("Run 'pip install python-docx'") as docx_import: + import docx + from docx.document import Document as DocxDocument + + +@component +class DocxToDocument: + """ + Converts Docx files to Documents. + + Uses `python-docx` library to convert the Docx file to a document. + This component does not preserve page brakes in the original document. + + Usage example: + ```python + from haystack.components.converters.docx import DocxToDocument + + converter = DocxToDocument() + results = converter.run(sources=["sample.docx"], meta={"date_added": datetime.now().isoformat()}) + documents = results["documents"] + print(documents[0].content) + # 'This is a text from the Docx file.' + ``` + """ + + def __init__(self): + """ + Create a DocxToDocument component. + """ + docx_import.check() + + @component.output_types(documents=List[Document]) + def run( + self, + sources: List[Union[str, Path, ByteStream]], + meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, + ): + """ + Converts Docx files to Documents. + + :param sources: + List of file paths or ByteStream objects. + :param meta: + Optional metadata to attach to the Documents. + This value can be either a list of dictionaries or a single dictionary. + If it's a single dictionary, its content is added to the metadata of all produced Documents. + If it's a list, the length of the list must match the number of sources, because the two lists will be zipped. + If `sources` contains ByteStream objects, their `meta` will be added to the output Documents. + + :returns: + A dictionary with the following keys: + - `documents`: Created Documents + """ + documents = [] + meta_list = normalize_metadata(meta=meta, sources_count=len(sources)) + + for source, metadata in zip(sources, meta_list): + # Load source ByteStream + try: + bytestream = get_bytestream_from_source(source) + except Exception as e: + logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e) + continue + + # Load the Docx Document + try: + file = docx.Document(io.BytesIO(bytestream.data)) + except Exception as e: + logger.warning( + "Could not read {source} and convert it to a Docx Document, skipping. Error: {error}", + source=source, + error=e, + ) + continue + + # Load the Metadata + try: + docx_meta = self._get_docx_metadata(document=file) + except Exception as e: + logger.warning( + "Could not load the metadata from {source}, skipping. Error: {error}", source=source, error=e + ) + docx_meta = {} + + # Load the content + try: + paragraphs = [para.text for para in file.paragraphs] + text = "\n".join(paragraphs) + except Exception as e: + logger.warning( + "Could not convert {source} to a Document, skipping it. Error: {error}", source=source, error=e + ) + continue + + merged_metadata = {**bytestream.meta, **docx_meta, **metadata} + document = Document(content=text, meta=merged_metadata) + + documents.append(document) + + return {"documents": documents} + + def _get_docx_metadata(self, document: DocxDocument) -> Dict[str, Union[str, int, datetime]]: + """ + Get all relevant data from the 'core_properties' attribute from a Docx Document. + + :param document: + The Docx Document you want to extract metadata from + + :returns: + A dictionary containing all the relevant fields from the 'core_properties' + """ + return { + "author": document.core_properties.author, + "category": document.core_properties.category, + "comments": document.core_properties.comments, + "content_status": document.core_properties.content_status, + "created": document.core_properties.created, + "identifier": document.core_properties.identifier, + "keywords": document.core_properties.keywords, + "language": document.core_properties.language, + "last_modified_by": document.core_properties.last_modified_by, + "last_printed": document.core_properties.last_printed, + "modified": document.core_properties.modified, + "revision": document.core_properties.revision, + "subject": document.core_properties.subject, + "title": document.core_properties.title, + "version": document.core_properties.version, + } diff --git a/pyproject.toml b/pyproject.toml index 4adef1523..f47c9d4cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,8 @@ extra-dependencies = [ "azure-ai-formrecognizer>=3.2.0b2", # AzureOCRDocumentConverter "trafilatura", # HTMLToDocument "python-pptx", # PPTXToDocument - + "python-docx", # DocxToDocument + # OpenAPI "jsonref", # OpenAPIServiceConnector, OpenAPIServiceToFunctions "openapi3", diff --git a/releasenotes/notes/add-docx-file-to-document-47b603755a00fbe6.yaml b/releasenotes/notes/add-docx-file-to-document-47b603755a00fbe6.yaml new file mode 100644 index 000000000..eb52b968f --- /dev/null +++ b/releasenotes/notes/add-docx-file-to-document-47b603755a00fbe6.yaml @@ -0,0 +1,6 @@ +--- +highlights: > + Adding the `DocxToDocument` component to convert Docx files to Documents. +features: + - | + Adding the `DocxToDocument` component inside the `converters` category. It uses the `python-docx` library to convert Docx files to haystack Documents. diff --git a/test/components/converters/test_docx_file_to_document.py b/test/components/converters/test_docx_file_to_document.py new file mode 100644 index 000000000..7ee6a149a --- /dev/null +++ b/test/components/converters/test_docx_file_to_document.py @@ -0,0 +1,63 @@ +import logging +from unittest.mock import patch + +import pytest + +from haystack.dataclasses import ByteStream +from haystack.components.converters import DocxToDocument + + +@pytest.fixture +def docx_converter(): + return DocxToDocument() + + +class TestDocxToDocument: + def test_init(self, docx_converter): + assert isinstance(docx_converter, DocxToDocument) + + @pytest.mark.integration + def test_run(self, test_files_path, docx_converter): + """ + Test if the component runs correctly + """ + paths = [test_files_path / "docx" / "sample_docx_1.docx"] + output = docx_converter.run(sources=paths) + docs = output["documents"] + assert len(docs) == 1 + assert "History" in docs[0].content + + def test_run_with_meta(self, test_files_path, docx_converter): + with patch("haystack.components.converters.docx.DocxToDocument"): + output = docx_converter.run( + sources=[test_files_path / "docx" / "sample_docx_1.docx"], + meta={"language": "it", "author": "test_author"}, + ) + + # check that the metadata from the bytestream is merged with that from the meta parameter + assert output["documents"][0].meta["author"] == "test_author" + assert output["documents"][0].meta["language"] == "it" + + def test_run_error_handling(self, test_files_path, docx_converter, caplog): + """ + Test if the component correctly handles errors. + """ + paths = ["non_existing_file.docx"] + with caplog.at_level(logging.WARNING): + docx_converter.run(sources=paths) + assert "Could not read non_existing_file.docx" in caplog.text + + @pytest.mark.integration + def test_mixed_sources_run(self, test_files_path, docx_converter): + """ + Test if the component runs correctly when mixed sources are provided. + """ + paths = [test_files_path / "docx" / "sample_docx_1.docx"] + with open(test_files_path / "docx" / "sample_docx_1.docx", "rb") as f: + paths.append(ByteStream(f.read())) + + output = docx_converter.run(sources=paths) + docs = output["documents"] + assert len(docs) == 2 + assert "History and standardization" in docs[0].content + assert "History and standardization" in docs[1].content diff --git a/test/test_files/docx/sample_docx_1.docx b/test/test_files/docx/sample_docx_1.docx new file mode 100644 index 0000000000000000000000000000000000000000..baa1f34d7e83dfe720a52ff4f0cce97e824e85a1 GIT binary patch literal 13342 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 DL_ZDx literal 0 HcmV?d00001