c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
411 lines
16 KiB
Python
411 lines
16 KiB
Python
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
import csv
|
|
import io
|
|
import os
|
|
from dataclasses import asdict, dataclass
|
|
from enum import Enum
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from haystack import Document, component, default_from_dict, default_to_dict, 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
|
|
from docx.table import Table
|
|
from docx.text.hyperlink import Hyperlink
|
|
from docx.text.paragraph import Paragraph
|
|
from docx.text.run import Run
|
|
from lxml.etree import _Comment
|
|
|
|
|
|
@dataclass
|
|
class DOCXMetadata:
|
|
"""
|
|
Describes the metadata of Docx file.
|
|
|
|
:param author: The author
|
|
:param category: The category
|
|
:param comments: The comments
|
|
:param content_status: The content status
|
|
:param created: The creation date (ISO formatted string)
|
|
:param identifier: The identifier
|
|
:param keywords: Available keywords
|
|
:param language: The language of the document
|
|
:param last_modified_by: User who last modified the document
|
|
:param last_printed: The last printed date (ISO formatted string)
|
|
:param modified: The last modification date (ISO formatted string)
|
|
:param revision: The revision number
|
|
:param subject: The subject
|
|
:param title: The title
|
|
:param version: The version
|
|
"""
|
|
|
|
author: str
|
|
category: str
|
|
comments: str
|
|
content_status: str
|
|
created: str | None
|
|
identifier: str
|
|
keywords: str
|
|
language: str
|
|
last_modified_by: str
|
|
last_printed: str | None
|
|
modified: str | None
|
|
revision: int
|
|
subject: str
|
|
title: str
|
|
version: str
|
|
|
|
|
|
class DOCXTableFormat(Enum):
|
|
"""
|
|
Supported formats for storing DOCX tabular data in a Document.
|
|
"""
|
|
|
|
MARKDOWN = "markdown"
|
|
CSV = "csv"
|
|
|
|
def __str__(self) -> str:
|
|
return self.value
|
|
|
|
@staticmethod
|
|
def from_str(string: str) -> "DOCXTableFormat":
|
|
"""
|
|
Convert a string to a DOCXTableFormat enum.
|
|
"""
|
|
enum_map = {e.value: e for e in DOCXTableFormat}
|
|
table_format = enum_map.get(string.lower())
|
|
if table_format is None:
|
|
msg = f"Unknown table format '{string}'. Supported formats are: {list(enum_map.keys())}"
|
|
raise ValueError(msg)
|
|
return table_format
|
|
|
|
|
|
class DOCXLinkFormat(Enum):
|
|
"""
|
|
Supported formats for storing DOCX link information in a Document.
|
|
"""
|
|
|
|
MARKDOWN = "markdown"
|
|
PLAIN = "plain"
|
|
NONE = "none"
|
|
|
|
def __str__(self) -> str:
|
|
return self.value
|
|
|
|
@staticmethod
|
|
def from_str(string: str) -> "DOCXLinkFormat":
|
|
"""
|
|
Convert a string to a DOCXLinkFormat enum.
|
|
"""
|
|
enum_map = {e.value: e for e in DOCXLinkFormat}
|
|
link_format = enum_map.get(string.lower())
|
|
if link_format is None:
|
|
msg = f"Unknown link format '{string}'. Supported formats are: {list(enum_map.keys())}"
|
|
raise ValueError(msg)
|
|
return link_format
|
|
|
|
|
|
@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 breaks in the original document.
|
|
|
|
Usage example:
|
|
|
|
```python
|
|
from haystack.components.converters.docx import DOCXToDocument, DOCXTableFormat, DOCXLinkFormat
|
|
from datetime import datetime
|
|
|
|
converter = DOCXToDocument(table_format=DOCXTableFormat.CSV, link_format=DOCXLinkFormat.MARKDOWN)
|
|
results = converter.run(
|
|
sources=["test/test_files/docx/sample_docx.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,
|
|
table_format: str | DOCXTableFormat = DOCXTableFormat.CSV,
|
|
link_format: str | DOCXLinkFormat = DOCXLinkFormat.NONE,
|
|
store_full_path: bool = False,
|
|
) -> None:
|
|
"""
|
|
Create a DOCXToDocument component.
|
|
|
|
:param table_format: The format for table output. Can be either DOCXTableFormat.MARKDOWN,
|
|
DOCXTableFormat.CSV, "markdown", or "csv".
|
|
:param link_format: The format for link output. Can be either:
|
|
DOCXLinkFormat.MARKDOWN or "markdown" to get `[text](address)`,
|
|
DOCXLinkFormat.PLAIN or "plain" to get text (address),
|
|
DOCXLinkFormat.NONE or "none" to get text without links.
|
|
:param store_full_path:
|
|
If True, the full path of the file is stored in the metadata of the document.
|
|
If False, only the file name is stored.
|
|
"""
|
|
docx_import.check()
|
|
self.table_format = DOCXTableFormat.from_str(table_format) if isinstance(table_format, str) else table_format
|
|
self.link_format = DOCXLinkFormat.from_str(link_format) if isinstance(link_format, str) else link_format
|
|
self.store_full_path = store_full_path
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""
|
|
Serializes the component to a dictionary.
|
|
|
|
:returns:
|
|
Dictionary with serialized data.
|
|
"""
|
|
return default_to_dict(
|
|
self,
|
|
table_format=str(self.table_format),
|
|
link_format=str(self.link_format),
|
|
store_full_path=self.store_full_path,
|
|
)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "DOCXToDocument":
|
|
"""
|
|
Deserializes the component from a dictionary.
|
|
|
|
:param data:
|
|
The dictionary to deserialize from.
|
|
:returns:
|
|
The deserialized component.
|
|
"""
|
|
if "table_format" in data["init_parameters"]:
|
|
data["init_parameters"]["table_format"] = DOCXTableFormat.from_str(data["init_parameters"]["table_format"])
|
|
if "link_format" in data["init_parameters"]:
|
|
data["init_parameters"]["link_format"] = DOCXLinkFormat.from_str(data["init_parameters"]["link_format"])
|
|
return default_from_dict(cls, data)
|
|
|
|
@component.output_types(documents=list[Document])
|
|
def run(
|
|
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
|
) -> dict[str, Any]:
|
|
"""
|
|
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, strict=True):
|
|
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
|
|
try:
|
|
docx_document = docx.Document(io.BytesIO(bytestream.data))
|
|
elements = self._extract_elements(docx_document)
|
|
text = "\n".join(elements)
|
|
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
|
|
|
|
docx_metadata = asdict(self._get_docx_metadata(document=docx_document))
|
|
merged_metadata = {**bytestream.meta, **metadata, "docx": docx_metadata}
|
|
|
|
if not self.store_full_path and "file_path" in bytestream.meta:
|
|
file_path = bytestream.meta.get("file_path")
|
|
if file_path: # Ensure the value is not None for mypy
|
|
merged_metadata["file_path"] = os.path.basename(file_path)
|
|
|
|
document = Document(content=text, meta=merged_metadata)
|
|
documents.append(document)
|
|
|
|
return {"documents": documents}
|
|
|
|
def _extract_elements(self, document: "DocxDocument") -> list[str]:
|
|
"""
|
|
Extracts elements from a DOCX file.
|
|
|
|
:param document: The DOCX Document object.
|
|
:returns: List of strings (paragraph texts and table representations) with page breaks added as '\f' characters.
|
|
"""
|
|
elements = []
|
|
for element in document.element.body:
|
|
if isinstance(element, _Comment):
|
|
continue
|
|
if element.tag.endswith("p"):
|
|
paragraph = Paragraph(element, document)
|
|
if paragraph.contains_page_break:
|
|
para_text = self._process_paragraph_with_page_breaks(paragraph)
|
|
else:
|
|
para_text = self._process_links_in_paragraph(paragraph)
|
|
elements.append(para_text)
|
|
elif element.tag.endswith("tbl"):
|
|
table = docx.table.Table(element, document)
|
|
table_str = (
|
|
self._table_to_markdown(table)
|
|
if self.table_format == DOCXTableFormat.MARKDOWN
|
|
else self._table_to_csv(table)
|
|
)
|
|
elements.append(table_str)
|
|
|
|
return elements
|
|
|
|
def _process_paragraph_with_page_breaks(self, paragraph: "Paragraph") -> str:
|
|
"""
|
|
Processes a paragraph with page breaks.
|
|
|
|
:param paragraph: The DOCX paragraph to process.
|
|
:returns: A string with page breaks added as '\f' characters.
|
|
"""
|
|
para_text = ""
|
|
# Usually, just 1 page break exists, but could be more if paragraph is really long, so we loop over them
|
|
for pb_index, page_break in enumerate(paragraph.rendered_page_breaks):
|
|
# Can only extract text from first paragraph page break, unfortunately
|
|
if pb_index == 0:
|
|
if page_break.preceding_paragraph_fragment:
|
|
para_text += self._process_links_in_paragraph(page_break.preceding_paragraph_fragment)
|
|
para_text += "\f"
|
|
if page_break.following_paragraph_fragment:
|
|
# following_paragraph_fragment contains all text for remainder of paragraph.
|
|
# However, if the remainder of the paragraph spans multiple page breaks, it won't include
|
|
# those later page breaks so we have to add them at end of text in the `else` block below.
|
|
# This is not ideal, but this case should be very rare and this is likely good enough.
|
|
para_text += self._process_links_in_paragraph(page_break.following_paragraph_fragment)
|
|
else:
|
|
para_text += "\f"
|
|
return para_text
|
|
|
|
def _process_links_in_paragraph(self, paragraph: "Paragraph") -> str:
|
|
"""
|
|
Processes links in a paragraph and formats them according to the specified link format.
|
|
|
|
:param paragraph: The DOCX paragraph to process.
|
|
:returns: A string with links formatted according to the specified format.
|
|
"""
|
|
if self.link_format == DOCXLinkFormat.NONE:
|
|
return paragraph.text
|
|
text = ""
|
|
# Iterate over all hyperlinks and other content in the paragraph
|
|
# https://python-docx.readthedocs.io/en/latest/api/text.html#docx.text.paragraph.Paragraph.iter_inner_content
|
|
for content in paragraph.iter_inner_content():
|
|
if isinstance(content, Run):
|
|
text += content.text
|
|
elif isinstance(content, Hyperlink):
|
|
if self.link_format == DOCXLinkFormat.MARKDOWN:
|
|
formatted_link = f"[{content.text}]({content.address})"
|
|
else: # PLAIN format
|
|
formatted_link = f"{content.text} ({content.address})"
|
|
text += formatted_link
|
|
|
|
return text
|
|
|
|
def _table_to_markdown(self, table: "Table") -> str:
|
|
"""
|
|
Converts a DOCX table to a Markdown string.
|
|
|
|
:param table: The DOCX table to convert.
|
|
:returns: A Markdown string representation of the table.
|
|
"""
|
|
markdown: list[str] = []
|
|
max_col_widths: list[int] = []
|
|
|
|
# Calculate max width for each column
|
|
for row in table.rows:
|
|
for i, cell in enumerate(row.cells):
|
|
cell_text = cell.text.strip()
|
|
if i >= len(max_col_widths):
|
|
max_col_widths.append(len(cell_text))
|
|
else:
|
|
max_col_widths[i] = max(max_col_widths[i], len(cell_text))
|
|
|
|
# Process rows
|
|
for i, row in enumerate(table.rows):
|
|
md_row = [cell.text.strip().ljust(max_col_widths[j]) for j, cell in enumerate(row.cells)]
|
|
markdown.append("| " + " | ".join(md_row) + " |")
|
|
|
|
# Add separator after header row
|
|
if i == 0:
|
|
separator = ["-" * max_col_widths[j] for j in range(len(row.cells))]
|
|
markdown.append("| " + " | ".join(separator) + " |")
|
|
|
|
return "\n".join(markdown)
|
|
|
|
def _table_to_csv(self, table: "Table") -> str:
|
|
"""
|
|
Converts a DOCX table to a CSV string.
|
|
|
|
:param table: The DOCX table to convert.
|
|
:returns: A CSV string representation of the table.
|
|
"""
|
|
csv_output = StringIO()
|
|
csv_writer = csv.writer(csv_output, quoting=csv.QUOTE_MINIMAL)
|
|
|
|
# Process rows
|
|
for row in table.rows:
|
|
csv_row = [cell.text.strip() for cell in row.cells]
|
|
csv_writer.writerow(csv_row)
|
|
|
|
# Get the CSV as a string and strip any trailing newlines
|
|
csv_string = csv_output.getvalue().strip()
|
|
csv_output.close()
|
|
|
|
return csv_string
|
|
|
|
def _get_docx_metadata(self, document: "DocxDocument") -> DOCXMetadata:
|
|
"""
|
|
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 `DOCXMetadata` dataclass all the relevant fields from the 'core_properties'
|
|
"""
|
|
return DOCXMetadata(
|
|
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.isoformat() if document.core_properties.created else None),
|
|
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.isoformat() if document.core_properties.last_printed else None
|
|
),
|
|
modified=(document.core_properties.modified.isoformat() if document.core_properties.modified else None),
|
|
revision=document.core_properties.revision,
|
|
subject=document.core_properties.subject,
|
|
title=document.core_properties.title,
|
|
version=document.core_properties.version,
|
|
)
|