chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"csv": ["CSVToDocument"],
|
||||
"docx": ["DOCXToDocument"],
|
||||
"file_to_file_content": ["FileToFileContent"],
|
||||
"html": ["HTMLToDocument"],
|
||||
"json": ["JSONConverter"],
|
||||
"markdown": ["MarkdownToDocument"],
|
||||
"msg": ["MSGToDocument"],
|
||||
"multi_file_converter": ["MultiFileConverter"],
|
||||
"output_adapter": ["OutputAdapter"],
|
||||
"pdfminer": ["PDFMinerToDocument"],
|
||||
"pptx": ["PPTXToDocument"],
|
||||
"pypdf": ["PyPDFToDocument"],
|
||||
"txt": ["TextFileToDocument"],
|
||||
"xlsx": ["XLSXToDocument"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .csv import CSVToDocument as CSVToDocument
|
||||
from .docx import DOCXToDocument as DOCXToDocument
|
||||
from .file_to_file_content import FileToFileContent as FileToFileContent
|
||||
from .html import HTMLToDocument as HTMLToDocument
|
||||
from .json import JSONConverter as JSONConverter
|
||||
from .markdown import MarkdownToDocument as MarkdownToDocument
|
||||
from .msg import MSGToDocument as MSGToDocument
|
||||
from .multi_file_converter import MultiFileConverter as MultiFileConverter
|
||||
from .output_adapter import OutputAdapter as OutputAdapter
|
||||
from .pdfminer import PDFMinerToDocument as PDFMinerToDocument
|
||||
from .pptx import PPTXToDocument as PPTXToDocument
|
||||
from .pypdf import PyPDFToDocument as PyPDFToDocument
|
||||
from .txt import TextFileToDocument as TextFileToDocument
|
||||
from .xlsx import XLSXToDocument as XLSXToDocument
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ROW_MODE_SIZE_WARN_BYTES = 5 * 1024 * 1024 # ~5MB; warn when parsing rows might be memory-heavy
|
||||
|
||||
|
||||
@component
|
||||
class CSVToDocument:
|
||||
"""
|
||||
Converts CSV files to Documents.
|
||||
|
||||
By default, it uses UTF-8 encoding when converting files but
|
||||
you can also set a custom encoding.
|
||||
It can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.csv import CSVToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = CSVToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/csv/sample_1.csv"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'col1,col2\\nrow1,row1\\nrow2,row2\\n'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encoding: str = "utf-8",
|
||||
store_full_path: bool = False,
|
||||
*,
|
||||
conversion_mode: Literal["file", "row"] = "file",
|
||||
delimiter: str = ",",
|
||||
quotechar: str = '"',
|
||||
) -> None:
|
||||
"""
|
||||
Creates a CSVToDocument component.
|
||||
|
||||
:param encoding:
|
||||
The encoding of the csv files to convert.
|
||||
If the encoding is specified in the metadata of a source ByteStream,
|
||||
it overrides this value.
|
||||
: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.
|
||||
:param conversion_mode:
|
||||
- "file" (default): one Document per CSV file whose content is the raw CSV text.
|
||||
- "row": convert each CSV row to its own Document (requires `content_column` in `run()`).
|
||||
:param delimiter:
|
||||
CSV delimiter used when parsing in row mode (passed to ``csv.DictReader``).
|
||||
:param quotechar:
|
||||
CSV quote character used when parsing in row mode (passed to ``csv.DictReader``).
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.store_full_path = store_full_path
|
||||
self.conversion_mode = conversion_mode
|
||||
self.delimiter = delimiter
|
||||
self.quotechar = quotechar
|
||||
|
||||
# Basic validation
|
||||
if len(self.delimiter) != 1:
|
||||
raise ValueError("CSVToDocument: delimiter must be a single character.")
|
||||
if len(self.quotechar) != 1:
|
||||
raise ValueError("CSVToDocument: quotechar must be a single character.")
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
*,
|
||||
content_column: str | None = None,
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts CSV files to a Document (file mode) or to one Document per row (row mode).
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects.
|
||||
:param content_column:
|
||||
**Required when** ``conversion_mode="row"``.
|
||||
The column name whose values become ``Document.content`` for each row.
|
||||
The column must exist in the CSV header.
|
||||
: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: list[Document] = []
|
||||
|
||||
meta_list = normalize_metadata(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:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
raw = io.BytesIO(bytestream.data).getvalue()
|
||||
data = raw.decode(encoding=encoding)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **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)
|
||||
|
||||
# Mode: file (backward-compatible default) -> one Document per file
|
||||
if self.conversion_mode == "file":
|
||||
documents.append(Document(content=data, meta=merged_metadata))
|
||||
continue
|
||||
|
||||
# --- ROW MODE (strict) ---
|
||||
# Require content_column in run(); no fallback
|
||||
if not content_column:
|
||||
raise ValueError(
|
||||
"CSVToDocument(row): 'content_column' is required in run() when conversion_mode='row'."
|
||||
)
|
||||
|
||||
# Warn for large CSVs in row mode (memory consideration)
|
||||
try:
|
||||
size_bytes = len(raw)
|
||||
if size_bytes > _ROW_MODE_SIZE_WARN_BYTES:
|
||||
logger.warning(
|
||||
"CSVToDocument(row): parsing a large CSV (~{mb:.1f} MB). "
|
||||
"Consider chunking/streaming if you hit memory issues.",
|
||||
mb=size_bytes / (1024 * 1024),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create DictReader; if this fails, raise (no fallback)
|
||||
try:
|
||||
# ``restkey`` ensures surplus fields on ragged rows (rows with more values than the
|
||||
# header, e.g. an unquoted comma inside a value) land under an explicit string key
|
||||
# instead of the default ``None`` key, which would break ``Document`` id generation.
|
||||
reader = csv.DictReader(
|
||||
io.StringIO(data), delimiter=self.delimiter, quotechar=self.quotechar, restkey="extra_columns"
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"CSVToDocument(row): could not parse CSV rows for {source}: {e}") from e
|
||||
|
||||
# Validate header contains content_column; strict error if missing
|
||||
header = reader.fieldnames or []
|
||||
if content_column not in header:
|
||||
raise ValueError(
|
||||
f"CSVToDocument(row): content_column='{content_column}' not found in header "
|
||||
f"for {source}. Available columns: {header}"
|
||||
)
|
||||
|
||||
# Build documents; if a row processing fails, raise immediately (no skip)
|
||||
for i, row in enumerate(reader):
|
||||
try:
|
||||
doc = self._build_document_from_row(
|
||||
row=row, base_meta=merged_metadata, row_index=i, content_column=content_column
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"CSVToDocument(row): failed to process row {i} for {source}: {e}") from e
|
||||
documents.append(doc)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
# ----- helpers -----
|
||||
def _safe_value(self, value: Any) -> str:
|
||||
"""Normalize CSV cell values: None -> '', everything -> str."""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
def _build_document_from_row(
|
||||
self, row: dict[str, Any], base_meta: dict[str, Any], row_index: int, content_column: str
|
||||
) -> Document:
|
||||
"""
|
||||
Build a ``Document`` from one parsed CSV row.
|
||||
|
||||
:param row: Mapping of column name to cell value for the current row
|
||||
(as produced by ``csv.DictReader``).
|
||||
:param base_meta: File-level and user-provided metadata to start from
|
||||
(for example: ``file_path``, ``encoding``).
|
||||
:param row_index: Zero-based row index in the CSV; stored as
|
||||
``row_number`` in the output document's metadata.
|
||||
:param content_column: Column name to use for ``Document.content``.
|
||||
:returns: A ``Document`` with chosen content and merged metadata.
|
||||
Remaining row columns are added to ``meta`` with collision-safe
|
||||
keys (prefixed with ``csv_`` if needed).
|
||||
"""
|
||||
row_meta = dict(base_meta)
|
||||
|
||||
# content (strict: content_column must exist; validated by caller)
|
||||
content = self._safe_value(row.get(content_column))
|
||||
|
||||
# merge remaining columns into meta with collision handling
|
||||
for k, v in row.items():
|
||||
if k == content_column:
|
||||
continue
|
||||
key_to_use = k
|
||||
if key_to_use in row_meta:
|
||||
# Avoid clobbering existing meta like file_path/encoding; prefix and de-dupe
|
||||
base_key = f"csv_{key_to_use}"
|
||||
key_to_use = base_key
|
||||
suffix = 1
|
||||
while key_to_use in row_meta:
|
||||
key_to_use = f"{base_key}_{suffix}"
|
||||
suffix += 1
|
||||
row_meta[key_to_use] = self._safe_value(v)
|
||||
|
||||
row_meta["row_number"] = row_index
|
||||
return Document(content=content, meta=row_meta)
|
||||
@@ -0,0 +1,410 @@
|
||||
# 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,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream, FileContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMPTY_BYTE_STRING = b""
|
||||
|
||||
|
||||
@component
|
||||
class FileToFileContent:
|
||||
"""
|
||||
Converts files to FileContent objects to be included in ChatMessage objects.
|
||||
|
||||
### Usage example
|
||||
<!-- test-ignore -->
|
||||
```python
|
||||
from haystack.components.converters import FileToFileContent
|
||||
|
||||
converter = FileToFileContent()
|
||||
sources = ["test/test_files/pdf/react_paper.pdf", "test/test_files/images/haystack-logo.png"]
|
||||
file_contents = converter.run(sources=sources)["file_contents"]
|
||||
|
||||
print(file_contents)
|
||||
# >> [FileContent(base64_data='...', mime_type='application/pdf', filename='react_paper.pdf', extra={}),
|
||||
# >> FileContent(base64_data='...', mime_type='image/png', filename='haystack-logo.png', extra={})
|
||||
# >>]
|
||||
```
|
||||
"""
|
||||
|
||||
@component.output_types(file_contents=list[FileContent])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], *, extra: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[FileContent]]:
|
||||
"""
|
||||
Converts files to FileContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param extra:
|
||||
Optional extra information to attach to the FileContent objects. Can be used to store provider-specific
|
||||
information.
|
||||
To avoid serialization issues, values should be JSON serializable.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the extra of all produced FileContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `file_contents`: A list of FileContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"file_contents": []}
|
||||
|
||||
file_contents = []
|
||||
|
||||
extra_list = normalize_metadata(extra, sources_count=len(sources))
|
||||
|
||||
for source, extra_dict in zip(sources, extra_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
filename = source.name if isinstance(source, Path) else None
|
||||
|
||||
try:
|
||||
bytestream = get_bytestream_from_source(source, guess_mime_type=True)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
if bytestream.data == _EMPTY_BYTE_STRING:
|
||||
logger.warning("File {source} is empty. Skipping it.", source=source)
|
||||
continue
|
||||
|
||||
base64_data = base64.b64encode(bytestream.data).decode("utf-8")
|
||||
# ``normalize_metadata`` returns the same dict object for every source when ``extra`` is a
|
||||
# single dict (or ``None``), so give each FileContent its own copy. Otherwise mutating one
|
||||
# file's ``extra`` downstream would leak into all the others. The other converters avoid this
|
||||
# implicitly by merging ``extra`` into a fresh ``{**bytestream.meta, ...}`` dict.
|
||||
file_content = FileContent(
|
||||
base64_data=base64_data, mime_type=bytestream.mime_type, filename=filename, extra=dict(extra_dict)
|
||||
)
|
||||
file_contents.append(file_content)
|
||||
|
||||
return {"file_contents": file_contents}
|
||||
@@ -0,0 +1,147 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
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 trafilatura'") as trafilatura_import:
|
||||
from trafilatura import extract
|
||||
|
||||
|
||||
@component
|
||||
class HTMLToDocument:
|
||||
"""
|
||||
Converts an HTML file to a Document.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack.components.converters import HTMLToDocument
|
||||
|
||||
converter = HTMLToDocument()
|
||||
results = converter.run(sources=["test/test_files/html/paul_graham_superlinear.html"])
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is a text from the HTML file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, extraction_kwargs: dict[str, Any] | None = None, store_full_path: bool = False, encoding: str = "utf-8"
|
||||
) -> None:
|
||||
"""
|
||||
Create an HTMLToDocument component.
|
||||
|
||||
:param extraction_kwargs: A dictionary containing keyword arguments to customize the extraction process. These
|
||||
are passed to the underlying Trafilatura `extract` function. For the full list of available arguments, see
|
||||
the [Trafilatura documentation](https://trafilatura.readthedocs.io/en/latest/corefunctions.html#extract).
|
||||
: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.
|
||||
:param encoding:
|
||||
The default encoding to use when converting HTML files. If the encoding is specified in the metadata of a
|
||||
source ByteStream, it overrides this value.
|
||||
"""
|
||||
trafilatura_import.check()
|
||||
|
||||
self.extraction_kwargs = extraction_kwargs or {}
|
||||
self.store_full_path = store_full_path
|
||||
self.encoding = encoding
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, extraction_kwargs=self.extraction_kwargs, store_full_path=self.store_full_path, encoding=self.encoding
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "HTMLToDocument":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
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,
|
||||
extraction_kwargs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Converts a list of HTML files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of HTML 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.
|
||||
:param extraction_kwargs:
|
||||
Additional keyword arguments to customize the extraction process.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: Created Documents
|
||||
"""
|
||||
|
||||
merged_extraction_kwargs = {**self.extraction_kwargs, **(extraction_kwargs or {})}
|
||||
|
||||
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=source)
|
||||
except Exception as e:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e)
|
||||
continue
|
||||
|
||||
if not bytestream.data:
|
||||
logger.warning("Skipping {source} because it is empty.", source=source)
|
||||
continue
|
||||
|
||||
try:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
text = extract(bytestream.data.decode(encoding), **merged_extraction_kwargs)
|
||||
except Exception as conversion_e:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source,
|
||||
error=conversion_e,
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **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}
|
||||
@@ -0,0 +1,23 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"document_to_image": ["DocumentToImageContent"],
|
||||
"file_to_document": ["ImageFileToDocument"],
|
||||
"file_to_image": ["ImageFileToImageContent"],
|
||||
"pdf_to_image": ["PDFToImageContent"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .document_to_image import DocumentToImageContent as DocumentToImageContent
|
||||
from .file_to_document import ImageFileToDocument as ImageFileToDocument
|
||||
from .file_to_image import ImageFileToImageContent as ImageFileToImageContent
|
||||
from .pdf_to_image import PDFToImageContent as PDFToImageContent
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.image.image_utils import (
|
||||
_batch_convert_pdf_pages_to_images,
|
||||
_encode_image_to_base64,
|
||||
_extract_image_sources_info,
|
||||
_PDFPageInfo,
|
||||
pillow_import,
|
||||
pypdfium2_import,
|
||||
)
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class DocumentToImageContent:
|
||||
"""
|
||||
Converts documents sourced from PDF and image files into ImageContents.
|
||||
|
||||
This component processes a list of documents and extracts visual content from supported file formats, converting
|
||||
them into ImageContents that can be used for multimodal AI tasks. It handles both direct image files and PDF
|
||||
documents by extracting specific pages as images.
|
||||
|
||||
Documents are expected to have metadata containing:
|
||||
- The `file_path_meta_field` key with a valid file path that exists when combined with `root_path`
|
||||
- A supported image format (MIME type must be one of the supported image types)
|
||||
- For PDF files, a `page_number` key specifying which page to extract
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.converters.image.document_to_image import DocumentToImageContent
|
||||
|
||||
converter = DocumentToImageContent(
|
||||
file_path_meta_field="file_path",
|
||||
root_path="test/test_files",
|
||||
detail="high",
|
||||
size=(800, 600)
|
||||
)
|
||||
|
||||
documents = [
|
||||
Document(content="Optional description of apple.jpg", meta={"file_path": "images/apple.jpg"}),
|
||||
Document(
|
||||
content="Optional description of sample_pdf_1.pdf",
|
||||
meta={"file_path": "pdf/sample_pdf_1.pdf", "page_number": 1}
|
||||
)
|
||||
]
|
||||
|
||||
result = converter.run(documents)
|
||||
image_contents = result["image_contents"]
|
||||
# [ImageContent(
|
||||
# base64_image='/9j/4A...', mime_type='image/jpeg', detail='high', meta={'file_path': 'images/apple.jpg'}
|
||||
# ),
|
||||
# ImageContent(
|
||||
# base64_image='/9j/4A...', mime_type='image/jpeg', detail='high',
|
||||
# meta={'file_path': 'pdf/sample_pdf_1.pdf', 'page_number': 1})
|
||||
# )]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
file_path_meta_field: str = "file_path",
|
||||
root_path: str | None = None,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the DocumentToImageContent component.
|
||||
|
||||
:param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF.
|
||||
:param root_path: The root directory path where document files are located. If provided, file paths in
|
||||
document metadata will be resolved relative to this path. If None, file paths are treated as absolute paths.
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). Can be "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
"""
|
||||
pillow_import.check()
|
||||
pypdfium2_import.check()
|
||||
|
||||
self.file_path_meta_field = file_path_meta_field
|
||||
self.root_path = root_path or ""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent | None])
|
||||
def run(self, documents: list[Document]) -> dict[str, list[ImageContent | None]]:
|
||||
"""
|
||||
Convert documents with image or PDF sources into ImageContent objects.
|
||||
|
||||
This method processes the input documents, extracting images from supported file formats and converting them
|
||||
into ImageContent objects.
|
||||
|
||||
:param documents: A list of documents to process. Each document should have metadata containing at minimum
|
||||
a 'file_path_meta_field' key. PDF documents additionally require a 'page_number' key to specify which
|
||||
page to convert.
|
||||
|
||||
:returns:
|
||||
Dictionary containing one key:
|
||||
- "image_contents": ImageContents created from the processed documents. These contain base64-encoded image
|
||||
data and metadata. The order corresponds to order of input documents.
|
||||
:raises ValueError:
|
||||
If any document is missing the required metadata keys, has an invalid file path, or has an unsupported
|
||||
MIME type. The error message will specify which document and what information is missing or incorrect.
|
||||
"""
|
||||
if not documents:
|
||||
return {"image_contents": []}
|
||||
|
||||
images_source_info = _extract_image_sources_info(
|
||||
documents=documents, file_path_meta_field=self.file_path_meta_field, root_path=self.root_path
|
||||
)
|
||||
|
||||
image_contents: list[ImageContent | None] = [None] * len(documents)
|
||||
|
||||
pdf_page_infos: list[_PDFPageInfo] = []
|
||||
|
||||
for doc_idx, image_source_info in enumerate(images_source_info):
|
||||
mime_type = image_source_info["mime_type"]
|
||||
path = image_source_info["path"]
|
||||
if mime_type == "application/pdf":
|
||||
# Store PDF documents for later processing
|
||||
page_number = image_source_info.get("page_number")
|
||||
assert page_number is not None # checked in _extract_image_sources_info but mypy doesn't know that
|
||||
pdf_page_info: _PDFPageInfo = {"doc_idx": doc_idx, "path": path, "page_number": page_number}
|
||||
pdf_page_infos.append(pdf_page_info)
|
||||
else:
|
||||
# Process images directly
|
||||
bytestream = ByteStream.from_file_path(filepath=path, mime_type=mime_type)
|
||||
_, base64_image = _encode_image_to_base64(bytestream=bytestream, size=self.size)
|
||||
image_contents[doc_idx] = ImageContent(
|
||||
base64_image=base64_image,
|
||||
mime_type=mime_type,
|
||||
detail=self.detail,
|
||||
meta={"file_path": documents[doc_idx].meta[self.file_path_meta_field]},
|
||||
)
|
||||
|
||||
# efficiently convert PDF pages to images: each PDF is opened and processed only once
|
||||
pdf_page_infos_by_doc_idx: dict[int, _PDFPageInfo] = {
|
||||
pdf_page_info["doc_idx"]: pdf_page_info for pdf_page_info in pdf_page_infos
|
||||
}
|
||||
pdf_images_by_doc_idx = _batch_convert_pdf_pages_to_images(
|
||||
pdf_page_infos=pdf_page_infos, size=self.size, return_base64=True
|
||||
)
|
||||
for doc_idx, base64_pdf_image in pdf_images_by_doc_idx.items():
|
||||
meta = {
|
||||
"file_path": documents[doc_idx].meta[self.file_path_meta_field],
|
||||
"page_number": pdf_page_infos_by_doc_idx[doc_idx]["page_number"],
|
||||
}
|
||||
# we know that base64_pdf_image is a string because we set return_base64=True but mypy doesn't know that
|
||||
assert isinstance(base64_pdf_image, str)
|
||||
image_contents[doc_idx] = ImageContent(
|
||||
base64_image=base64_pdf_image, mime_type="image/jpeg", detail=self.detail, meta=meta
|
||||
)
|
||||
|
||||
none_image_contents_doc_ids = [
|
||||
documents[doc_idx].id for doc_idx, image_content in enumerate(image_contents) if image_content is None
|
||||
]
|
||||
if none_image_contents_doc_ids:
|
||||
logger.warning(
|
||||
"Conversion failed for some documents. Their output will be None. Document IDs: {document_ids}",
|
||||
document_ids=none_image_contents_doc_ids,
|
||||
)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,98 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class ImageFileToDocument:
|
||||
"""
|
||||
Converts image file references into empty Document objects with associated metadata.
|
||||
|
||||
This component is useful in pipelines where image file paths need to be wrapped in `Document` objects to be
|
||||
processed by downstream components such as the `LLMDocumentContentExtractor` or the
|
||||
`SentenceTransformersDocumentImageEmbedder` (available in the `sentence-transformers-haystack` integration).
|
||||
|
||||
It does **not** extract any content from the image files, instead it creates `Document` objects with `None` as
|
||||
their content and attaches metadata such as file path and any user-provided values.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import ImageFileToDocument
|
||||
|
||||
converter = ImageFileToDocument()
|
||||
|
||||
sources = ["image.jpg", "another_image.png"]
|
||||
|
||||
result = converter.run(sources=sources)
|
||||
documents = result["documents"]
|
||||
|
||||
print(documents)
|
||||
|
||||
# [Document(id=..., meta: {'file_path': 'image.jpg'}),
|
||||
# Document(id=..., meta: {'file_path': 'another_image.png'})]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Initialize the ImageFileToDocument component.
|
||||
|
||||
: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.
|
||||
"""
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@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, list[Document]]:
|
||||
"""
|
||||
Convert image files into empty Document objects with metadata.
|
||||
|
||||
This method accepts image file references (as file paths or ByteStreams) and creates `Document` objects
|
||||
without content. These documents are enriched with metadata derived from the input source and optional
|
||||
user-provided metadata.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be 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, its length must match the number of sources, as they are zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: A list of `Document` objects with empty content and associated metadata.
|
||||
"""
|
||||
|
||||
documents = []
|
||||
meta_list = normalize_metadata(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
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=None, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,150 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import mimetypes
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.image.image_utils import _encode_image_to_base64
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport(
|
||||
"The 'size' parameter is set. "
|
||||
"Image resizing will be applied, which requires the Pillow library. "
|
||||
"Run 'pip install pillow'"
|
||||
) as pillow_import:
|
||||
import PIL # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMPTY_BYTE_STRING = b""
|
||||
|
||||
|
||||
@component
|
||||
class ImageFileToImageContent:
|
||||
"""
|
||||
Converts image files to ImageContent objects.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import ImageFileToImageContent
|
||||
|
||||
converter = ImageFileToImageContent()
|
||||
|
||||
sources = ["image.jpg", "another_image.png"]
|
||||
|
||||
image_contents = converter.run(sources=sources)["image_contents"]
|
||||
print(image_contents)
|
||||
|
||||
# [ImageContent(base64_image='...',
|
||||
# mime_type='image/jpeg',
|
||||
# detail=None,
|
||||
# meta={'file_path': 'image.jpg'}),
|
||||
# ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *, detail: Literal["auto", "high", "low"] | None = None, size: tuple[int, int] | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Create the ImageFileToImageContent component.
|
||||
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
"""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
|
||||
if self.size is not None:
|
||||
pillow_import.check()
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> dict[str, list[ImageContent]]:
|
||||
"""
|
||||
Converts files to ImageContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the ImageContent objects.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced ImageContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output ImageContent objects.
|
||||
:param detail:
|
||||
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
If not provided, the detail level will be the one set in the constructor.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
If not provided, the size value will be the one set in the constructor.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `image_contents`: A list of ImageContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"image_contents": []}
|
||||
|
||||
resolved_detail = detail or self.detail
|
||||
resolved_size = size or self.size
|
||||
|
||||
# Check import
|
||||
if resolved_size:
|
||||
pillow_import.check()
|
||||
|
||||
image_contents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
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
|
||||
|
||||
if bytestream.mime_type is None and isinstance(source, Path):
|
||||
bytestream = replace(bytestream, mime_type=mimetypes.guess_type(source.as_posix())[0])
|
||||
|
||||
if bytestream.data == _EMPTY_BYTE_STRING:
|
||||
logger.warning("File {source} is empty. Skipping it.", source=source)
|
||||
continue
|
||||
|
||||
try:
|
||||
inferred_mime_type, base64_image = _encode_image_to_base64(bytestream=bytestream, size=resolved_size)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
image_content = ImageContent(
|
||||
base64_image=base64_image, mime_type=inferred_mime_type, meta=merged_metadata, detail=resolved_detail
|
||||
)
|
||||
image_contents.append(image_content)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,338 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, Union
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from haystack import logging
|
||||
from haystack.dataclasses import ByteStream, Document
|
||||
from haystack.dataclasses.image_content import IMAGE_MIME_TYPES, MIME_TO_FORMAT
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
with LazyImport("Run 'pip install pypdfium2'") as pypdfium2_import:
|
||||
from pypdfium2 import PdfDocument
|
||||
|
||||
with LazyImport("Run 'pip install pillow'") as pillow_import:
|
||||
from PIL import Image as PILImage
|
||||
from PIL.Image import Image
|
||||
from PIL.ImageFile import ImageFile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _encode_image_to_base64(bytestream: ByteStream, size: tuple[int, int] | None = None) -> tuple[str | None, str]:
|
||||
"""
|
||||
Encode an image from a ByteStream into a base64-encoded string.
|
||||
|
||||
Optionally resize the image before encoding to improve performance for downstream processing.
|
||||
|
||||
:param bytestream: ByteStream containing the image data.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
|
||||
:returns:
|
||||
A tuple (mime_type, base64_str), where:
|
||||
- mime_type (Optional[str]): The mime type of the encoded image, determined from the original data or image
|
||||
content. Can be None if the mime type cannot be reliably identified.
|
||||
- base64_str (str): The base64-encoded string representation of the (optionally resized) image.
|
||||
"""
|
||||
if size is None:
|
||||
if bytestream.mime_type is None:
|
||||
logger.warning(
|
||||
"No mime type provided for the image. "
|
||||
"This may cause compatibility issues with downstream systems requiring a specific mime type. "
|
||||
"Please provide a mime type for the image."
|
||||
)
|
||||
return bytestream.mime_type, base64.b64encode(bytestream.data).decode("utf-8")
|
||||
|
||||
# Check the import
|
||||
pillow_import.check()
|
||||
|
||||
# Load the image
|
||||
if bytestream.mime_type and bytestream.mime_type in MIME_TO_FORMAT:
|
||||
formats = [MIME_TO_FORMAT[bytestream.mime_type]]
|
||||
else:
|
||||
formats = None
|
||||
image: "ImageFile" = PILImage.open(BytesIO(bytestream.data), formats=formats)
|
||||
|
||||
# NOTE: We prefer the format returned by PIL
|
||||
inferred_mime_type = image.get_format_mimetype() or bytestream.mime_type
|
||||
|
||||
# Downsize the image in place
|
||||
if size is not None:
|
||||
# Set reducing_gap=None to disable multi-step shrink; better quality.
|
||||
# https://pillow.readthedocs.io/en/latest/reference/Image.html#PIL.Image.Image.thumbnail
|
||||
image.thumbnail(size=size, reducing_gap=None)
|
||||
|
||||
# Convert the image to base64 string
|
||||
if not inferred_mime_type:
|
||||
logger.warning(
|
||||
"Could not determine mime type for image. Defaulting to 'image/jpeg'. "
|
||||
"Consider providing a mime_type parameter."
|
||||
)
|
||||
inferred_mime_type = "image/jpeg"
|
||||
return inferred_mime_type, _encode_pil_image_to_base64(image=image, mime_type=inferred_mime_type)
|
||||
|
||||
|
||||
def _encode_pil_image_to_base64(image: Union["Image", "ImageFile"], mime_type: str = "image/jpeg") -> str:
|
||||
"""
|
||||
Convert a PIL Image object to a base64-encoded string.
|
||||
|
||||
Automatically converts images with transparency to RGB if saving as JPEG.
|
||||
|
||||
:param image: A PIL Image or ImageFile object to encode.
|
||||
:param mime_type: The MIME type to use when encoding the image. Defaults to "image/jpeg".
|
||||
:returns:
|
||||
Base64-encoded string representing the image.
|
||||
"""
|
||||
# Check the import
|
||||
pillow_import.check()
|
||||
|
||||
# Convert image to RGB if it has an alpha channel and we are saving as JPEG
|
||||
if (mime_type == "image/jpeg" or mime_type == "image/jpg") and (
|
||||
image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||
):
|
||||
image = image.convert("RGB")
|
||||
|
||||
buffered = BytesIO()
|
||||
form = MIME_TO_FORMAT.get(mime_type)
|
||||
if form is None:
|
||||
logger.warning("Could not determine format for mime type {mime_type}. Defaulting to JPEG.", mime_type=mime_type)
|
||||
form = "JPEG"
|
||||
image.save(buffered, format=form)
|
||||
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def _convert_pdf_to_images(
|
||||
*,
|
||||
bytestream: ByteStream,
|
||||
return_base64: bool = False,
|
||||
page_range: list[int] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
) -> list[tuple[int, "Image"]] | list[tuple[int, str]]:
|
||||
"""
|
||||
Convert a PDF file into a list of PIL Image objects or base64-encoded images.
|
||||
|
||||
Checks PDF dimensions and adjusts size constraints based on aspect ratio.
|
||||
|
||||
:param bytestream: ByteStream object containing the PDF data
|
||||
:param return_base64: If True, return base64-encoded images instead of PIL images.
|
||||
:param page_range: List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
|
||||
:returns:
|
||||
A list of tuples, each tuple containing the page number and the PIL Image object or base64-encoded image string.
|
||||
"""
|
||||
|
||||
pypdfium2_import.check()
|
||||
pillow_import.check()
|
||||
|
||||
try:
|
||||
pdf = PdfDocument(BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read PDF file {file_path}. Skipping it. Error: {error}",
|
||||
file_path=bytestream.meta.get("file_path"),
|
||||
error=e,
|
||||
)
|
||||
return []
|
||||
|
||||
num_pages = len(pdf)
|
||||
if num_pages == 0:
|
||||
logger.warning("PDF file is empty: {file_path}", file_path=bytestream.meta.get("file_path"))
|
||||
pdf.close()
|
||||
return []
|
||||
|
||||
all_pdf_images = []
|
||||
|
||||
resolved_page_range = page_range or range(1, num_pages + 1)
|
||||
|
||||
for page_number in resolved_page_range:
|
||||
if page_number < 1 or page_number > num_pages:
|
||||
logger.warning("Page {page_number} is out of range for the PDF file. Skipping it.", page_number=page_number)
|
||||
continue
|
||||
|
||||
# Get dimensions of the page
|
||||
page = pdf[max(page_number - 1, 0)] # Adjust for 0-based indexing
|
||||
_, _, width, height = page.get_mediabox()
|
||||
|
||||
target_resolution_dpi = 300.0
|
||||
|
||||
# From pypdfium2 docs: scale (float) – A factor scaling the number of pixels per PDF canvas unit. This defines
|
||||
# the resolution of the image. To convert a DPI value to a scale factor, multiply it by the size of 1 canvas
|
||||
# unit in inches (usually 1/72in).
|
||||
# https://pypdfium2.readthedocs.io/en/stable/python_api.html#pypdfium2._helpers.page.PdfPage.render
|
||||
target_scale = target_resolution_dpi / 72.0
|
||||
|
||||
# Calculate potential pixels for target_dpi
|
||||
pixels_for_target_scale = width * height * target_scale**2
|
||||
|
||||
pil_max_pixels = PILImage.MAX_IMAGE_PIXELS or int(1024 * 1024 * 1024 // 4 // 3)
|
||||
# 90% of PIL's default limit to prevent borderline cases
|
||||
pixel_limit = pil_max_pixels * 0.9
|
||||
|
||||
scale = target_scale
|
||||
if pixels_for_target_scale > pixel_limit:
|
||||
logger.info(
|
||||
"Large PDF detected ({pixels:.2f} pixels). Resizing the image to fit the pixel limit.",
|
||||
pixels=pixels_for_target_scale,
|
||||
)
|
||||
scale = (pixel_limit / (width * height)) ** 0.5
|
||||
|
||||
pdf_bitmap = page.render(scale=scale)
|
||||
|
||||
image: "Image" = pdf_bitmap.to_pil()
|
||||
pdf_bitmap.close()
|
||||
if size is not None:
|
||||
# Set reducing_gap=None to disable multi-step shrink; better quality.
|
||||
# https://pillow.readthedocs.io/en/latest/reference/Image.html#PIL.Image.Image.thumbnail
|
||||
image.thumbnail(size=size, reducing_gap=None)
|
||||
|
||||
all_pdf_images.append((page_number, image))
|
||||
|
||||
pdf.close()
|
||||
|
||||
if return_base64:
|
||||
return [
|
||||
(page_number, _encode_pil_image_to_base64(image, mime_type="image/jpeg"))
|
||||
for page_number, image in all_pdf_images
|
||||
]
|
||||
|
||||
return all_pdf_images
|
||||
|
||||
|
||||
class _ImageSourceInfo(TypedDict):
|
||||
path: Path
|
||||
mime_type: str | None
|
||||
page_number: NotRequired[int] # Only present for PDF documents
|
||||
|
||||
|
||||
def _extract_image_sources_info(
|
||||
documents: list[Document], file_path_meta_field: str, root_path: str
|
||||
) -> list[_ImageSourceInfo]:
|
||||
"""
|
||||
Extracts the image source information from the documents.
|
||||
|
||||
:param documents: List of documents to extract image source information from.
|
||||
:param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF.
|
||||
:param root_path: The root directory path where document files are located.
|
||||
|
||||
:returns:
|
||||
A list of _ImageSourceInfo dictionaries, each containing the path and type of the image.
|
||||
If the image is a PDF, the dictionary also contains the page number.
|
||||
:raises ValueError: If the document is missing the file_path_meta_field key in its metadata, the file path is
|
||||
invalid, the MIME type is not supported, or the page number is missing for a PDF document.
|
||||
"""
|
||||
images_source_info: list[_ImageSourceInfo] = []
|
||||
for doc in documents:
|
||||
file_path = doc.meta.get(file_path_meta_field)
|
||||
if file_path is None:
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' is missing the '{file_path_meta_field}' key in its metadata."
|
||||
f" Please ensure that the documents you are trying to convert have this key set."
|
||||
)
|
||||
|
||||
resolved_file_path = Path(root_path, file_path)
|
||||
|
||||
# When root_path is set, ensure the resolved path stays within it to block path-traversal
|
||||
# payloads (e.g. "../../etc/passwd") coming from document metadata.
|
||||
if root_path:
|
||||
resolved_file_path = resolved_file_path.resolve()
|
||||
resolved_root = Path(root_path).resolve()
|
||||
if not resolved_file_path.is_relative_to(resolved_root):
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' has a file path '{file_path}' that escapes the "
|
||||
f"configured root '{root_path}'. Resolved path: '{resolved_file_path}'."
|
||||
)
|
||||
|
||||
if not resolved_file_path.is_file():
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' has an invalid file path '{resolved_file_path}'. "
|
||||
f"Please ensure that the documents you are trying to convert have valid file paths."
|
||||
)
|
||||
|
||||
mime_type = doc.meta.get("mime_type") or mimetypes.guess_type(resolved_file_path)[0]
|
||||
if mime_type not in IMAGE_MIME_TYPES:
|
||||
raise ValueError(
|
||||
f"Document with file path '{resolved_file_path}' has an unsupported MIME type '{mime_type}'. "
|
||||
f"Please ensure that the documents you are trying to convert are of the supported "
|
||||
f"types: {', '.join(IMAGE_MIME_TYPES)}."
|
||||
)
|
||||
|
||||
image_info: _ImageSourceInfo = {"path": resolved_file_path, "mime_type": mime_type}
|
||||
|
||||
# If mimetype is PDF we also need the page number to be able to convert the right page
|
||||
if mime_type == "application/pdf":
|
||||
page_number = doc.meta.get("page_number")
|
||||
if page_number is None:
|
||||
raise ValueError(
|
||||
f"Document with ID '{doc.id}' comes from the PDF file '{resolved_file_path}' but is missing "
|
||||
f"the 'page_number' key in its metadata. Please ensure that PDF documents you are trying to "
|
||||
f"convert have this key set."
|
||||
)
|
||||
image_info["page_number"] = page_number
|
||||
|
||||
images_source_info.append(image_info)
|
||||
|
||||
return images_source_info
|
||||
|
||||
|
||||
class _PDFPageInfo(TypedDict):
|
||||
doc_idx: int
|
||||
path: Path
|
||||
page_number: int
|
||||
|
||||
|
||||
def _batch_convert_pdf_pages_to_images(
|
||||
*, pdf_page_infos: list[_PDFPageInfo], return_base64: bool = False, size: tuple[int, int] | None = None
|
||||
) -> dict[int, str] | dict[int, "Image"]:
|
||||
"""
|
||||
Converts selected PDF pages to images, returning a mapping from document indices to images (PIL or base64).
|
||||
|
||||
Pages are grouped by file path to ensure each PDF is opened and processed only once for efficiency.
|
||||
|
||||
:param pdf_page_infos: List of _PDFPageInfo dictionaries with doc_idx, path, and page_number.
|
||||
:param size: Optional tuple of width and height to resize the images to.
|
||||
:param return_base64: If True, return base64 encoded images instead of PIL images.
|
||||
|
||||
:returns: Dictionary mapping document indices to images (PIL.Image or base64 string).
|
||||
"""
|
||||
if not pdf_page_infos:
|
||||
return {}
|
||||
|
||||
page_infos_by_pdf_path = defaultdict(list)
|
||||
for page_info in pdf_page_infos:
|
||||
page_infos_by_pdf_path[page_info["path"]].append(page_info)
|
||||
|
||||
converted_images_by_doc_index = {}
|
||||
|
||||
for pdf_path, page_infos_for_pdf in page_infos_by_pdf_path.items():
|
||||
page_numbers_to_convert = [info["page_number"] for info in page_infos_for_pdf]
|
||||
bytestream = ByteStream.from_file_path(pdf_path)
|
||||
|
||||
converted_pages = _convert_pdf_to_images(
|
||||
bytestream=bytestream, return_base64=return_base64, page_range=page_numbers_to_convert, size=size
|
||||
)
|
||||
|
||||
# Map results back to document indices
|
||||
page_number_to_image = dict(converted_pages)
|
||||
for page_info in page_infos_for_pdf:
|
||||
converted_images_by_doc_index[page_info["doc_idx"]] = page_number_to_image[page_info["page_number"]]
|
||||
|
||||
# mypy is not able to infer that we match the declared return type
|
||||
return converted_images_by_doc_index # type: ignore[return-value]
|
||||
@@ -0,0 +1,155 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, logging
|
||||
from haystack.components.converters.image.image_utils import _convert_pdf_to_images, pillow_import, pypdfium2_import
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
from haystack.dataclasses.image_content import ImageContent
|
||||
from haystack.utils import expand_page_range
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class PDFToImageContent:
|
||||
"""
|
||||
Converts PDF files to ImageContent objects.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack.components.converters.image import PDFToImageContent
|
||||
|
||||
converter = PDFToImageContent()
|
||||
|
||||
sources = ["file.pdf", "another_file.pdf"]
|
||||
|
||||
image_contents = converter.run(sources=sources)["image_contents"]
|
||||
print(image_contents)
|
||||
|
||||
# [ImageContent(base64_image='...',
|
||||
# mime_type='application/pdf',
|
||||
# detail=None,
|
||||
# meta={'file_path': 'file.pdf', 'page_number': 1}),
|
||||
# ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
page_range: list[str | int] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create the PDFToImageContent component.
|
||||
|
||||
:param detail: Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
:param size: If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
:param page_range: List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
"""
|
||||
self.detail = detail
|
||||
self.size = size
|
||||
self.page_range = page_range
|
||||
pypdfium2_import.check()
|
||||
pillow_import.check()
|
||||
|
||||
@component.output_types(image_contents=list[ImageContent])
|
||||
def run(
|
||||
self,
|
||||
sources: list[str | Path | ByteStream],
|
||||
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
detail: Literal["auto", "high", "low"] | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
page_range: list[str | int] | None = None,
|
||||
) -> dict[str, list[ImageContent]]:
|
||||
"""
|
||||
Converts files to ImageContent objects.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the ImageContent objects.
|
||||
This value can be a list of dictionaries or a single dictionary.
|
||||
If it's a single dictionary, its content is added to the metadata of all produced ImageContent objects.
|
||||
If it's a list, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output ImageContent objects.
|
||||
:param detail:
|
||||
Optional detail level of the image (only supported by OpenAI). One of "auto", "high", or "low".
|
||||
This will be passed to the created ImageContent objects.
|
||||
If not provided, the detail level will be the one set in the constructor.
|
||||
:param size:
|
||||
If provided, resizes the image to fit within the specified dimensions (width, height) while
|
||||
maintaining aspect ratio. This reduces file size, memory usage, and processing time, which is beneficial
|
||||
when working with models that have resolution constraints or when transmitting images to remote services.
|
||||
If not provided, the size value will be the one set in the constructor.
|
||||
:param page_range:
|
||||
List of page numbers and/or page ranges to convert to images. Page numbers start at 1.
|
||||
If None, all pages in the PDF will be converted. Pages outside the valid range (1 to number of pages)
|
||||
will be skipped with a warning. For example, page_range=[1, 3] will convert only the first and third
|
||||
pages of the document. It also accepts printable range strings, e.g.: ['1-3', '5', '8', '10-12']
|
||||
will convert pages 1, 2, 3, 5, 8, 10, 11, 12.
|
||||
If not provided, the page_range value will be the one set in the constructor.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `image_contents`: A list of ImageContent objects.
|
||||
"""
|
||||
if not sources:
|
||||
return {"image_contents": []}
|
||||
|
||||
resolved_detail = detail or self.detail
|
||||
resolved_size = size or self.size
|
||||
resolved_page_range = page_range or self.page_range
|
||||
|
||||
expanded_page_range = expand_page_range(resolved_page_range) if resolved_page_range else None
|
||||
|
||||
image_contents = []
|
||||
|
||||
meta_list = normalize_metadata(meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in zip(sources, meta_list, strict=True):
|
||||
if isinstance(source, str):
|
||||
source = Path(source)
|
||||
|
||||
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:
|
||||
page_num_and_base64_images = _convert_pdf_to_images(
|
||||
bytestream=bytestream, page_range=expanded_page_range, size=resolved_size, return_base64=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
for page_number, image in page_num_and_base64_images:
|
||||
per_page_metadata = {**merged_metadata, "page_number": page_number}
|
||||
# we already know that image is a string because we set return_base64=True but mypy doesn't know that
|
||||
assert isinstance(image, str)
|
||||
image_contents.append(
|
||||
ImageContent(
|
||||
base64_image=image, mime_type="image/jpeg", meta=per_page_metadata, detail=resolved_detail
|
||||
)
|
||||
)
|
||||
|
||||
return {"image_contents": image_contents}
|
||||
@@ -0,0 +1,289 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import 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, Document
|
||||
from haystack.lazy_imports import LazyImport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with LazyImport("Run 'pip install jq'") as jq_import:
|
||||
import jq
|
||||
|
||||
|
||||
@component
|
||||
class JSONConverter:
|
||||
"""
|
||||
Converts one or more JSON files into a text document.
|
||||
|
||||
### Usage examples
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack.components.converters import JSONConverter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
source = ByteStream.from_string(json.dumps({"text": "This is the content of my document"}))
|
||||
|
||||
converter = JSONConverter(content_key="text")
|
||||
results = converter.run(sources=[source])
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'This is the content of my document'
|
||||
```
|
||||
|
||||
Optionally, you can also provide a `jq_schema` string to filter the JSON source files and `extra_meta_fields`
|
||||
to extract from the filtered data:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from haystack.components.converters import JSONConverter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
data = {
|
||||
"laureates": [
|
||||
{
|
||||
"firstname": "Enrico",
|
||||
"surname": "Fermi",
|
||||
"motivation": "for his demonstrations of the existence of new radioactive elements produced "
|
||||
"by neutron irradiation, and for his related discovery of nuclear reactions brought about by"
|
||||
" slow neutrons",
|
||||
},
|
||||
{
|
||||
"firstname": "Rita",
|
||||
"surname": "Levi-Montalcini",
|
||||
"motivation": "for their discoveries of growth factors",
|
||||
},
|
||||
],
|
||||
}
|
||||
source = ByteStream.from_string(json.dumps(data))
|
||||
converter = JSONConverter(
|
||||
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
|
||||
)
|
||||
|
||||
results = converter.run(sources=[source])
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'for his demonstrations of the existence of new radioactive elements produced by
|
||||
# neutron irradiation, and for his related discovery of nuclear reactions brought
|
||||
# about by slow neutrons'
|
||||
|
||||
print(documents[0].meta)
|
||||
# {'firstname': 'Enrico', 'surname': 'Fermi'}
|
||||
|
||||
print(documents[1].content)
|
||||
# 'for their discoveries of growth factors'
|
||||
|
||||
print(documents[1].meta)
|
||||
# {'firstname': 'Rita', 'surname': 'Levi-Montalcini'}
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jq_schema: str | None = None,
|
||||
content_key: str | None = None,
|
||||
extra_meta_fields: set[str] | Literal["*"] | None = None,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a JSONConverter component.
|
||||
|
||||
An optional `jq_schema` can be provided to extract nested data in the JSON source files.
|
||||
See the [official jq documentation](https://jqlang.github.io/jq/) for more info on the filters syntax.
|
||||
If `jq_schema` is not set, whole JSON source files will be used to extract content.
|
||||
|
||||
Optionally, you can provide a `content_key` to specify which key in the extracted object must
|
||||
be set as the document's content.
|
||||
|
||||
If both `jq_schema` and `content_key` are set, the component will search for the `content_key` in
|
||||
the JSON object extracted by `jq_schema`. If the extracted data is not a JSON object, it will be skipped.
|
||||
|
||||
If only `jq_schema` is set, the extracted data must be a scalar value. If it's a JSON object or array,
|
||||
it will be skipped.
|
||||
|
||||
If only `content_key` is set, the source JSON file must be a JSON object, else it will be skipped.
|
||||
|
||||
`extra_meta_fields` can either be set to a set of strings or a literal `"*"` string.
|
||||
If it's a set of strings, it must specify fields in the extracted objects that must be set in
|
||||
the extracted documents. If a field is not found, the meta value will be `None`.
|
||||
If set to `"*"`, all fields that are not `content_key` found in the filtered JSON object will
|
||||
be saved as metadata.
|
||||
|
||||
Initialization will fail if neither `jq_schema` nor `content_key` are set.
|
||||
|
||||
:param jq_schema:
|
||||
Optional jq filter string to extract content.
|
||||
If not specified, whole JSON object will be used to extract information.
|
||||
:param content_key:
|
||||
Optional key to extract document content.
|
||||
If `jq_schema` is specified, the `content_key` will be extracted from that object.
|
||||
:param extra_meta_fields:
|
||||
An optional set of meta keys to extract from the content.
|
||||
If `jq_schema` is specified, all keys will be extracted from that object.
|
||||
: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.
|
||||
"""
|
||||
self._compiled_filter = None
|
||||
if jq_schema:
|
||||
jq_import.check()
|
||||
self._compiled_filter = jq.compile(jq_schema)
|
||||
|
||||
self._jq_schema = jq_schema
|
||||
self._content_key = content_key
|
||||
self._meta_fields = extra_meta_fields
|
||||
self._store_full_path = store_full_path
|
||||
|
||||
if self._compiled_filter is None and self._content_key is None:
|
||||
msg = "No `jq_schema` nor `content_key` specified. Set either or both to extract data."
|
||||
raise ValueError(msg)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
jq_schema=self._jq_schema,
|
||||
content_key=self._content_key,
|
||||
extra_meta_fields=self._meta_fields,
|
||||
store_full_path=self._store_full_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "JSONConverter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary to deserialize from.
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _get_content_and_meta(self, source: ByteStream) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Utility function to extract text and metadata from a JSON file.
|
||||
|
||||
:param source:
|
||||
UTF-8 byte stream.
|
||||
:returns:
|
||||
Collection of text and metadata dict tuples, each corresponding
|
||||
to a different document.
|
||||
"""
|
||||
try:
|
||||
file_content = source.data.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source.meta["file_path"],
|
||||
error=exc,
|
||||
)
|
||||
return []
|
||||
|
||||
meta_fields = self._meta_fields or set()
|
||||
|
||||
if self._compiled_filter is not None:
|
||||
try:
|
||||
objects = list(self._compiled_filter.input_text(file_content))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source.meta["file_path"],
|
||||
error=exc,
|
||||
)
|
||||
return []
|
||||
else:
|
||||
# We just load the whole file as JSON if the user didn't provide a jq filter.
|
||||
# We put it in a list even if it's not to ease handling it later on.
|
||||
objects = [json.loads(file_content)]
|
||||
|
||||
result = []
|
||||
if self._content_key is not None:
|
||||
for obj in objects:
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning("Expected a dictionary but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
if self._content_key not in obj:
|
||||
logger.warning(
|
||||
"'{content_key}' not found in {obj}. Skipping it.", content_key=self._content_key, obj=obj
|
||||
)
|
||||
continue
|
||||
|
||||
text = obj[self._content_key]
|
||||
if isinstance(text, (dict, list)):
|
||||
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
|
||||
meta = {}
|
||||
if meta_fields == "*":
|
||||
meta = {k: v for k, v in obj.items() if k != self._content_key}
|
||||
else:
|
||||
for field in meta_fields:
|
||||
meta[field] = obj.get(field, None)
|
||||
result.append((text, meta))
|
||||
else:
|
||||
for obj in objects:
|
||||
if isinstance(obj, (dict, list)):
|
||||
logger.warning("Expected a scalar value but got {obj}. Skipping it.", obj=obj)
|
||||
continue
|
||||
result.append((str(obj), {}))
|
||||
|
||||
return result
|
||||
|
||||
@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 a list of JSON files to documents.
|
||||
|
||||
:param sources:
|
||||
A 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.
|
||||
If `sources` contain ByteStream objects, their `meta` will be added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of 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 exc:
|
||||
logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=exc)
|
||||
continue
|
||||
|
||||
data = self._get_content_and_meta(bytestream)
|
||||
|
||||
for text, extra_meta in data:
|
||||
merged_metadata = {**bytestream.meta, **metadata, **extra_meta}
|
||||
|
||||
if not self._store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from tqdm import tqdm
|
||||
|
||||
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
|
||||
|
||||
with LazyImport("Run 'pip install markdown-it-py mdit_plain'") as markdown_conversion_imports:
|
||||
from markdown_it import MarkdownIt
|
||||
from mdit_plain.renderer import RendererPlain
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FRONTMATTER_PATTERN = re.compile(r"\A---[ \t]*\r?\n(?P<frontmatter>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|$)", re.DOTALL)
|
||||
|
||||
|
||||
@component
|
||||
class MarkdownToDocument:
|
||||
"""
|
||||
Converts a Markdown file into a text Document.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters import MarkdownToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = MarkdownToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/markdown/sample.md"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
print(documents[0].content)
|
||||
# 'This is a text from the markdown file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_to_single_line: bool = False,
|
||||
progress_bar: bool = True,
|
||||
store_full_path: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
*,
|
||||
extract_frontmatter: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a MarkdownToDocument component.
|
||||
|
||||
:param table_to_single_line:
|
||||
If True converts table contents into a single line.
|
||||
:param progress_bar:
|
||||
If True shows a progress bar when running.
|
||||
: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.
|
||||
:param encoding:
|
||||
The default encoding to use when converting Markdown files. If the encoding is specified in the metadata
|
||||
of a source ByteStream, it overrides this value.
|
||||
:param extract_frontmatter:
|
||||
If True, YAML frontmatter at the beginning of the Markdown file is
|
||||
removed from the document content and added to the document metadata.
|
||||
"""
|
||||
markdown_conversion_imports.check()
|
||||
|
||||
self.table_to_single_line = table_to_single_line
|
||||
self.progress_bar = progress_bar
|
||||
self.store_full_path = store_full_path
|
||||
self.encoding = encoding
|
||||
self.extract_frontmatter = extract_frontmatter
|
||||
|
||||
@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 a list of Markdown 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`: List of created Documents
|
||||
"""
|
||||
parser = MarkdownIt(renderer_cls=RendererPlain)
|
||||
if self.table_to_single_line:
|
||||
parser.enable("table")
|
||||
|
||||
documents = []
|
||||
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
|
||||
|
||||
for source, metadata in tqdm(
|
||||
zip(sources, meta_list, strict=True),
|
||||
total=len(sources),
|
||||
desc="Converting markdown files to Documents",
|
||||
disable=not self.progress_bar,
|
||||
):
|
||||
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:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
file_content = bytestream.data.decode(encoding)
|
||||
file_content, frontmatter = self._extract_frontmatter(file_content, source)
|
||||
text = parser.render(file_content)
|
||||
except Exception as conversion_e:
|
||||
logger.warning(
|
||||
"Failed to extract text from {source}. Skipping it. Error: {error}",
|
||||
source=source,
|
||||
error=conversion_e,
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **frontmatter, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
def _extract_frontmatter(self, file_content: str, source: str | Path | ByteStream) -> tuple[str, dict[str, Any]]:
|
||||
if not self.extract_frontmatter:
|
||||
return file_content, {}
|
||||
|
||||
match = _FRONTMATTER_PATTERN.match(file_content)
|
||||
if not match:
|
||||
return file_content, {}
|
||||
|
||||
frontmatter_text = match.group("frontmatter")
|
||||
try:
|
||||
frontmatter = json.loads(json.dumps(yaml.safe_load(frontmatter_text), default=str)) or {}
|
||||
except yaml.YAMLError as error:
|
||||
logger.warning(
|
||||
"Could not parse YAML frontmatter in {source}. Keeping it as content. Error: {error}",
|
||||
source=source,
|
||||
error=error,
|
||||
)
|
||||
return file_content, {}
|
||||
except (TypeError, ValueError) as error:
|
||||
logger.warning(
|
||||
"Could not convert YAML frontmatter in {source}. Keeping it as content. Error: {error}",
|
||||
source=source,
|
||||
error=error,
|
||||
)
|
||||
return file_content, {}
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
logger.warning(
|
||||
"Ignoring YAML frontmatter in {source}: expected a mapping, got {kind}.",
|
||||
source=source,
|
||||
kind=type(frontmatter).__name__,
|
||||
)
|
||||
return file_content, {}
|
||||
|
||||
return file_content[match.end() :], frontmatter
|
||||
@@ -0,0 +1,192 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
with LazyImport("Run 'pip install python-oxmsg'") as oxmsg_import:
|
||||
from oxmsg import Message, recipient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class MSGToDocument:
|
||||
"""
|
||||
Converts Microsoft Outlook .msg files into Haystack Documents.
|
||||
|
||||
This component extracts email metadata (such as sender, recipients, CC, BCC, subject) and body content from .msg
|
||||
files and converts them into structured Haystack Documents. Additionally, any file attachments within the .msg
|
||||
file are extracted as ByteStream objects.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from haystack.components.converters.msg import MSGToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = MSGToDocument()
|
||||
results = converter.run(sources=["test/test_files/msg/sample.msg"], meta={"date_added": datetime.now().isoformat()})
|
||||
documents = results["documents"]
|
||||
attachments = results["attachments"]
|
||||
print(documents[0].content)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Creates a MSGToDocument component.
|
||||
|
||||
: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.
|
||||
"""
|
||||
oxmsg_import.check()
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@staticmethod
|
||||
def _is_encrypted(msg: "Message") -> bool:
|
||||
"""
|
||||
Determines whether the provided MSG file is encrypted.
|
||||
|
||||
:param msg: The MSG file as a parsed Message object.
|
||||
:returns: True if the MSG file is encrypted, otherwise False.
|
||||
"""
|
||||
return "encrypted" in msg.message_headers.get("Content-Type", "")
|
||||
|
||||
@staticmethod
|
||||
def _create_recipient_str(recip: "recipient.Recipient") -> str:
|
||||
"""
|
||||
Formats a recipient's name and email into a single string.
|
||||
|
||||
:param recip: A recipient object extracted from the MSG file.
|
||||
:returns: A formatted string combining the recipient's name and email address.
|
||||
"""
|
||||
recip_str = ""
|
||||
if recip.name != "":
|
||||
recip_str += f"{recip.name} "
|
||||
if recip.email_address != "":
|
||||
recip_str += f"{recip.email_address}"
|
||||
return recip_str
|
||||
|
||||
def _convert(self, file_content: io.BytesIO) -> tuple[str, list[ByteStream]]:
|
||||
"""
|
||||
Converts the MSG file content into text and extracts any attachments.
|
||||
|
||||
:param file_content: The MSG file content as a binary stream.
|
||||
:returns: A tuple containing the extracted email text and a list of ByteStream objects for attachments.
|
||||
:raises ValueError: If the MSG file is encrypted and cannot be read.
|
||||
"""
|
||||
msg = Message.load(file_content)
|
||||
if self._is_encrypted(msg):
|
||||
raise ValueError("The MSG file is encrypted and cannot be read.")
|
||||
|
||||
txt = ""
|
||||
|
||||
# Sender
|
||||
if msg.sender is not None:
|
||||
txt += f"From: {msg.sender}\n"
|
||||
|
||||
# To
|
||||
recipients_str = ",".join(self._create_recipient_str(r) for r in msg.recipients)
|
||||
if recipients_str != "":
|
||||
txt += f"To: {recipients_str}\n"
|
||||
|
||||
# CC
|
||||
cc_header = msg.message_headers.get("Cc") or msg.message_headers.get("CC")
|
||||
if cc_header is not None:
|
||||
txt += f"Cc: {cc_header}\n"
|
||||
|
||||
# BCC
|
||||
bcc_header = msg.message_headers.get("Bcc") or msg.message_headers.get("BCC")
|
||||
if bcc_header is not None:
|
||||
txt += f"Bcc: {bcc_header}\n"
|
||||
|
||||
# Subject
|
||||
if msg.subject != "":
|
||||
txt += f"Subject: {msg.subject}\n"
|
||||
|
||||
# Body
|
||||
if msg.body is not None:
|
||||
txt += "\n" + msg.body
|
||||
|
||||
# attachments
|
||||
attachments = [
|
||||
ByteStream(
|
||||
data=attachment.file_bytes, meta={"file_path": attachment.file_name}, mime_type=attachment.mime_type
|
||||
)
|
||||
for attachment in msg.attachments
|
||||
if attachment.file_bytes is not None
|
||||
]
|
||||
|
||||
return txt, attachments
|
||||
|
||||
@component.output_types(documents=list[Document], attachments=list[ByteStream])
|
||||
def run(
|
||||
self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document] | list[ByteStream]]:
|
||||
"""
|
||||
Converts MSG 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.
|
||||
- `attachments`: Created ByteStream objects from file attachments.
|
||||
"""
|
||||
if len(sources) == 0:
|
||||
return {"documents": [], "attachments": []}
|
||||
|
||||
documents = []
|
||||
all_attachments = []
|
||||
meta_list = normalize_metadata(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:
|
||||
text, attachments = self._convert(io.BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
merged_metadata["file_path"] = os.path.basename(bytestream.meta["file_path"])
|
||||
|
||||
documents.append(Document(content=text, meta=merged_metadata))
|
||||
for attachment in attachments:
|
||||
attachment_meta = {
|
||||
**merged_metadata,
|
||||
"parent_file_path": merged_metadata["file_path"],
|
||||
"file_path": attachment.meta["file_path"],
|
||||
}
|
||||
all_attachments.append(
|
||||
ByteStream(data=attachment.data, meta=attachment_meta, mime_type=attachment.mime_type)
|
||||
)
|
||||
|
||||
return {"documents": documents, "attachments": all_attachments}
|
||||
@@ -0,0 +1,133 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from haystack import Document, Pipeline, super_component
|
||||
from haystack.components.converters import (
|
||||
CSVToDocument,
|
||||
DOCXToDocument,
|
||||
HTMLToDocument,
|
||||
JSONConverter,
|
||||
PPTXToDocument,
|
||||
PyPDFToDocument,
|
||||
TextFileToDocument,
|
||||
XLSXToDocument,
|
||||
)
|
||||
from haystack.components.joiners import DocumentJoiner
|
||||
from haystack.components.routers import FileTypeRouter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class ConverterMimeType(str, Enum):
|
||||
CSV = "text/csv"
|
||||
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
HTML = "text/html"
|
||||
JSON = "application/json"
|
||||
MD = "text/markdown"
|
||||
TEXT = "text/plain"
|
||||
PDF = "application/pdf"
|
||||
PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
|
||||
@super_component
|
||||
class MultiFileConverter:
|
||||
"""
|
||||
A file converter that handles conversion of multiple file types.
|
||||
|
||||
The MultiFileConverter handles the following file types:
|
||||
- CSV
|
||||
- DOCX
|
||||
- HTML
|
||||
- JSON
|
||||
- MD
|
||||
- TEXT
|
||||
- PDF (no OCR)
|
||||
- PPTX
|
||||
- XLSX
|
||||
|
||||
Usage example:
|
||||
```
|
||||
from haystack.super_components.converters import MultiFileConverter
|
||||
|
||||
converter = MultiFileConverter()
|
||||
converter.run(sources=["test/test_files/txt/doc_1.txt", "test/test_files/pdf/sample_pdf_1.pdf"], meta={})
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", json_content_key: str = "content") -> None:
|
||||
"""
|
||||
Initialize the MultiFileConverter.
|
||||
|
||||
:param encoding: The encoding to use when reading files.
|
||||
:param json_content_key: The key to use in a content field in a document when converting JSON files.
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.json_content_key = json_content_key
|
||||
|
||||
# initialize components
|
||||
router = FileTypeRouter(
|
||||
mime_types=[mime_type.value for mime_type in ConverterMimeType],
|
||||
# Ensure common extensions are registered. Tests on Windows fail otherwise.
|
||||
additional_mimetypes={
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
},
|
||||
)
|
||||
|
||||
# Create pipeline and add components
|
||||
pp = Pipeline()
|
||||
|
||||
pp.add_component("router", router)
|
||||
pp.add_component("docx", DOCXToDocument(link_format="markdown"))
|
||||
pp.add_component(
|
||||
"html",
|
||||
HTMLToDocument(
|
||||
extraction_kwargs={"output_format": "markdown", "include_tables": True, "include_links": True}
|
||||
),
|
||||
)
|
||||
pp.add_component("json", JSONConverter(content_key=self.json_content_key))
|
||||
pp.add_component("md", TextFileToDocument(encoding=self.encoding))
|
||||
pp.add_component("text", TextFileToDocument(encoding=self.encoding))
|
||||
pp.add_component("pdf", PyPDFToDocument())
|
||||
pp.add_component("pptx", PPTXToDocument())
|
||||
pp.add_component("xlsx", XLSXToDocument())
|
||||
pp.add_component("joiner", DocumentJoiner())
|
||||
pp.add_component("csv", CSVToDocument(encoding=self.encoding))
|
||||
|
||||
for mime_type in ConverterMimeType:
|
||||
pp.connect(f"router.{mime_type.value}", str(mime_type).lower().rsplit(".", maxsplit=1)[-1])
|
||||
|
||||
pp.connect("docx.documents", "joiner.documents")
|
||||
pp.connect("html.documents", "joiner.documents")
|
||||
pp.connect("json.documents", "joiner.documents")
|
||||
pp.connect("md.documents", "joiner.documents")
|
||||
pp.connect("text.documents", "joiner.documents")
|
||||
pp.connect("pdf.documents", "joiner.documents")
|
||||
pp.connect("pptx.documents", "joiner.documents")
|
||||
|
||||
pp.connect("csv.documents", "joiner.documents")
|
||||
pp.connect("xlsx.documents", "joiner.documents")
|
||||
|
||||
self.pipeline = pp
|
||||
self.output_mapping = {
|
||||
"joiner.documents": "documents",
|
||||
"router.unclassified": "unclassified",
|
||||
"router.failed": "failed",
|
||||
}
|
||||
self.input_mapping = {"sources": ["router.sources"], "meta": ["router.meta"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# fake method, never executed, but static analyzers will not complain about missing method
|
||||
def run( # noqa: D102
|
||||
self, *, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None
|
||||
) -> dict[str, list[Document]]: # noqa: D102
|
||||
...
|
||||
|
||||
def warm_up(self) -> None: # noqa: D102
|
||||
...
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
import jinja2.runtime
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from jinja2.nativetypes import NativeEnvironment
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict, logging
|
||||
from haystack.utils import deserialize_callable, deserialize_type, serialize_callable, serialize_type
|
||||
from haystack.utils.jinja2_extensions import _extract_template_variables_and_assignments
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputAdaptationException(Exception):
|
||||
"""Exception raised when there is an error during output adaptation."""
|
||||
|
||||
|
||||
@component
|
||||
class OutputAdapter:
|
||||
"""
|
||||
Adapts output of a Component using Jinja templates.
|
||||
|
||||
Usage example:
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.converters import OutputAdapter
|
||||
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
documents = [Document(content="Test content")]
|
||||
result = adapter.run(documents=documents)
|
||||
|
||||
assert result["output"] == "Test content"
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template: str,
|
||||
output_type: TypeAlias,
|
||||
custom_filters: dict[str, Callable] | None = None,
|
||||
unsafe: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create an OutputAdapter component.
|
||||
|
||||
:param template:
|
||||
A Jinja template that defines how to adapt the input data.
|
||||
The variables in the template define the input of this instance.
|
||||
e.g.
|
||||
With this template:
|
||||
```
|
||||
{{ documents[0].content }}
|
||||
```
|
||||
The Component input will be `documents`.
|
||||
:param output_type:
|
||||
The type of output this instance will return.
|
||||
:param custom_filters:
|
||||
A dictionary of custom Jinja filters used in the template.
|
||||
:param unsafe:
|
||||
Enable execution of arbitrary code in the Jinja template.
|
||||
This should only be used if you trust the source of the template as it can be lead to remote code execution.
|
||||
"""
|
||||
self.custom_filters = {**(custom_filters or {})}
|
||||
input_types: set[str] = set()
|
||||
|
||||
self._unsafe = unsafe
|
||||
|
||||
if self._unsafe:
|
||||
msg = (
|
||||
"Unsafe mode is enabled. This allows execution of arbitrary code in the Jinja template. "
|
||||
"Use this only if you trust the source of the template."
|
||||
)
|
||||
logger.warning(msg)
|
||||
self._env = (
|
||||
NativeEnvironment() if self._unsafe else SandboxedEnvironment(undefined=jinja2.runtime.StrictUndefined)
|
||||
)
|
||||
|
||||
try:
|
||||
self._env.parse(template) # Validate template syntax
|
||||
self.template = template
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValueError(f"Invalid Jinja template '{template}': {e}") from e
|
||||
|
||||
for name, filter_func in self.custom_filters.items():
|
||||
self._env.filters[name] = filter_func
|
||||
|
||||
# b) extract variables in the template
|
||||
assigned_variables, template_variables = _extract_template_variables_and_assignments(
|
||||
env=self._env, template=self.template
|
||||
)
|
||||
route_input_names = template_variables - assigned_variables
|
||||
input_types.update(route_input_names)
|
||||
|
||||
# the env is not needed, discarded automatically
|
||||
component.set_input_types(self, **dict.fromkeys(input_types, Any))
|
||||
component.set_output_types(self, output=output_type)
|
||||
self.output_type = output_type
|
||||
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Renders the Jinja template with the provided inputs.
|
||||
|
||||
:param kwargs:
|
||||
Must contain all variables used in the `template` string.
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `output`: Rendered Jinja template.
|
||||
|
||||
:raises OutputAdaptationException: If template rendering fails.
|
||||
"""
|
||||
# check if kwargs are empty
|
||||
if not kwargs:
|
||||
raise ValueError("No input data provided for output adaptation")
|
||||
for name, filter_func in self.custom_filters.items():
|
||||
self._env.filters[name] = filter_func
|
||||
adapted_outputs = {}
|
||||
try:
|
||||
adapted_output_template = self._env.from_string(self.template)
|
||||
output_result = adapted_output_template.render(**kwargs)
|
||||
if isinstance(output_result, jinja2.runtime.Undefined):
|
||||
raise OutputAdaptationException(f"Undefined variable in the template {self.template}; kwargs: {kwargs}") # noqa: TRY301
|
||||
|
||||
# We suppress the exception in case the output is already a string, otherwise
|
||||
# we try to evaluate it and would fail.
|
||||
# This must be done cause the output could be different literal structures.
|
||||
# This doesn't support any user types.
|
||||
with contextlib.suppress(Exception):
|
||||
if not self._unsafe:
|
||||
output_result = ast.literal_eval(output_result)
|
||||
|
||||
adapted_outputs["output"] = output_result
|
||||
except Exception as e:
|
||||
raise OutputAdaptationException(f"Error adapting {self.template} with {kwargs}: {e}") from e
|
||||
return adapted_outputs
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
se_filters = {name: serialize_callable(filter_func) for name, filter_func in self.custom_filters.items()}
|
||||
return default_to_dict(
|
||||
self,
|
||||
template=self.template,
|
||||
output_type=serialize_type(self.output_type),
|
||||
custom_filters=se_filters,
|
||||
unsafe=self._unsafe,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
init_params["output_type"] = deserialize_type(init_params["output_type"])
|
||||
|
||||
custom_filters = init_params.get("custom_filters", {})
|
||||
if custom_filters:
|
||||
init_params["custom_filters"] = {
|
||||
name: deserialize_callable(filter_func) if filter_func else None
|
||||
for name, filter_func in custom_filters.items()
|
||||
}
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,228 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
with LazyImport("Run 'pip install pdfminer.six'") as pdfminer_import:
|
||||
from pdfminer.high_level import extract_pages
|
||||
from pdfminer.layout import LAParams, LTTextContainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CID_PATTERN = r"\(cid:\d+\)" # regex pattern to detect CID characters
|
||||
|
||||
|
||||
@component
|
||||
class PDFMinerToDocument:
|
||||
"""
|
||||
Converts PDF files to Documents.
|
||||
|
||||
Uses `pdfminer` compatible converters to convert PDF files to Documents. https://pdfminersix.readthedocs.io/en/latest/
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pdfminer import PDFMinerToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PDFMinerToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pdf/sample_pdf_1.pdf"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
|
||||
print(results["documents"][0].content)
|
||||
# >> 'This is a text from the PDF file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
line_overlap: float = 0.5,
|
||||
char_margin: float = 2.0,
|
||||
line_margin: float = 0.5,
|
||||
word_margin: float = 0.1,
|
||||
boxes_flow: float | None = 0.5,
|
||||
detect_vertical: bool = True,
|
||||
all_texts: bool = False,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create a PDFMinerToDocument component.
|
||||
|
||||
:param line_overlap:
|
||||
This parameter determines whether two characters are considered to be on
|
||||
the same line based on the amount of overlap between them.
|
||||
The overlap is calculated relative to the minimum height of both characters.
|
||||
:param char_margin:
|
||||
Determines whether two characters are part of the same line based on the distance between them.
|
||||
If the distance is less than the margin specified, the characters are considered to be on the same line.
|
||||
The margin is calculated relative to the width of the character.
|
||||
:param word_margin:
|
||||
Determines whether two characters on the same line are part of the same word
|
||||
based on the distance between them. If the distance is greater than the margin specified,
|
||||
an intermediate space will be added between them to make the text more readable.
|
||||
The margin is calculated relative to the width of the character.
|
||||
:param line_margin:
|
||||
This parameter determines whether two lines are part of the same paragraph based on
|
||||
the distance between them. If the distance is less than the margin specified,
|
||||
the lines are considered to be part of the same paragraph.
|
||||
The margin is calculated relative to the height of a line.
|
||||
:param boxes_flow:
|
||||
This parameter determines the importance of horizontal and vertical position when
|
||||
determining the order of text boxes. A value between -1.0 and +1.0 can be set,
|
||||
with -1.0 indicating that only horizontal position matters and +1.0 indicating
|
||||
that only vertical position matters. Setting the value to 'None' will disable advanced
|
||||
layout analysis, and text boxes will be ordered based on the position of their bottom left corner.
|
||||
:param detect_vertical:
|
||||
This parameter determines whether vertical text should be considered during layout analysis.
|
||||
:param all_texts:
|
||||
If layout analysis should be performed on text in figures.
|
||||
: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.
|
||||
"""
|
||||
|
||||
pdfminer_import.check()
|
||||
|
||||
self.layout_params = LAParams(
|
||||
line_overlap=line_overlap,
|
||||
char_margin=char_margin,
|
||||
line_margin=line_margin,
|
||||
word_margin=word_margin,
|
||||
boxes_flow=boxes_flow,
|
||||
detect_vertical=detect_vertical,
|
||||
all_texts=all_texts,
|
||||
)
|
||||
self.store_full_path = store_full_path
|
||||
self.cid_pattern = re.compile(CID_PATTERN)
|
||||
|
||||
@staticmethod
|
||||
def _converter(lt_page_objs: Iterator) -> str:
|
||||
"""
|
||||
Extracts text from PDF pages then converts the text into a single str
|
||||
|
||||
:param lt_page_objs:
|
||||
Python generator that yields PDF pages.
|
||||
|
||||
:returns:
|
||||
PDF text converted to single str
|
||||
"""
|
||||
pages = []
|
||||
for page in lt_page_objs:
|
||||
text = ""
|
||||
for container in page:
|
||||
# Keep text only
|
||||
if isinstance(container, LTTextContainer):
|
||||
container_text = container.get_text()
|
||||
if container_text:
|
||||
text += "\n\n"
|
||||
text += container_text
|
||||
pages.append(text)
|
||||
|
||||
# Add a page delimiter
|
||||
return "\f".join(pages)
|
||||
|
||||
def detect_undecoded_cid_characters(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Look for character sequences of CID, i.e.: characters that haven't been properly decoded from their CID format.
|
||||
|
||||
This is useful to detect if the text extractor is not able to extract the text correctly, e.g. if the PDF uses
|
||||
non-standard fonts.
|
||||
|
||||
A PDF font may include a ToUnicode map (mapping from character code to Unicode) to support operations like
|
||||
searching strings or copy & paste in a PDF viewer. This map immediately provides the mapping the text extractor
|
||||
needs. If that map is not available the text extractor cannot decode the CID characters and will return them
|
||||
as is.
|
||||
|
||||
see: https://pdfminersix.readthedocs.io/en/latest/faq.html#why-are-there-cid-x-values-in-the-textual-output
|
||||
|
||||
:param text: The text to check for undecoded CID characters
|
||||
:returns:
|
||||
A dictionary containing detection results
|
||||
"""
|
||||
|
||||
matches = re.findall(self.cid_pattern, text)
|
||||
total_chars = len(text)
|
||||
cid_chars = sum(len(match) for match in matches)
|
||||
percentage = (cid_chars / total_chars * 100) if total_chars > 0 else 0
|
||||
|
||||
return {"total_chars": total_chars, "cid_chars": cid_chars, "percentage": round(percentage, 2)}
|
||||
|
||||
@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 PDF files to Documents.
|
||||
|
||||
:param sources:
|
||||
List of PDF 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, 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:
|
||||
pages = extract_pages(io.BytesIO(bytestream.data), laparams=self.layout_params)
|
||||
text = self._converter(pages)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
if text is None or text.strip() == "":
|
||||
logger.warning(
|
||||
"PDFMinerToDocument could not extract text from the file {source}. Returning an empty document.",
|
||||
source=source,
|
||||
)
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
analysis = self.detect_undecoded_cid_characters(text)
|
||||
|
||||
if analysis["percentage"] > 0:
|
||||
logger.warning(
|
||||
"Detected {cid_chars} undecoded CID characters in {total_chars} characters"
|
||||
" ({percentage}%) in {source}.",
|
||||
cid_chars=analysis["cid_chars"],
|
||||
total_chars=analysis["total_chars"],
|
||||
percentage=analysis["percentage"],
|
||||
source=source,
|
||||
)
|
||||
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,158 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import Document, component, 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
|
||||
|
||||
with LazyImport("Run 'pip install python-pptx'") as pptx_import:
|
||||
from pptx import Presentation
|
||||
from pptx.text.text import _Paragraph
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class PPTXToDocument:
|
||||
"""
|
||||
Converts PPTX files to Documents.
|
||||
|
||||
Usage example:
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pptx import PPTXToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PPTXToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pptx/sample_pptx.pptx"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is the text from the PPTX file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, store_full_path: bool = False, link_format: Literal["markdown", "plain", "none"] = "none"
|
||||
) -> None:
|
||||
"""
|
||||
Create a PPTXToDocument component.
|
||||
|
||||
: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.
|
||||
:param link_format: The format for link output. Possible options:
|
||||
- `"markdown"`: `[text](url)`
|
||||
- `"plain"`: `text (url)`
|
||||
- `"none"`: Only the text is extracted, link addresses are ignored.
|
||||
"""
|
||||
pptx_import.check()
|
||||
if link_format not in ("markdown", "plain", "none"):
|
||||
msg = f"Unknown link format '{link_format}'. Supported formats are: 'markdown', 'plain', 'none'"
|
||||
raise ValueError(msg)
|
||||
self.link_format = 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, link_format=self.link_format, store_full_path=self.store_full_path)
|
||||
|
||||
def _convert(self, file_content: io.BytesIO) -> str:
|
||||
"""
|
||||
Converts the PPTX file to text.
|
||||
"""
|
||||
pptx_presentation = Presentation(file_content)
|
||||
text_all_slides = []
|
||||
for slide in pptx_presentation.slides:
|
||||
text_on_slide = []
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
paragraphs = []
|
||||
for paragraph in shape.text_frame.paragraphs:
|
||||
paragraphs.append(self._process_paragraph(paragraph))
|
||||
text_on_slide.append("\n".join(paragraphs))
|
||||
elif hasattr(shape, "text"):
|
||||
text_on_slide.append(shape.text)
|
||||
text_all_slides.append("\n".join(text_on_slide))
|
||||
return "\f".join(text_all_slides)
|
||||
|
||||
def _process_paragraph(self, paragraph: "_Paragraph") -> str:
|
||||
"""
|
||||
Processes a paragraph and formats hyperlinks according to the specified link format.
|
||||
|
||||
:param paragraph: The PPTX paragraph to process.
|
||||
:returns: A string with links formatted according to the specified format.
|
||||
"""
|
||||
if self.link_format == "none":
|
||||
return paragraph.text
|
||||
parts = []
|
||||
for run in paragraph.runs:
|
||||
if run.hyperlink and run.hyperlink.address:
|
||||
if self.link_format == "markdown":
|
||||
parts.append(f"[{run.text}]({run.hyperlink.address})")
|
||||
else:
|
||||
parts.append(f"{run.text} ({run.hyperlink.address})")
|
||||
else:
|
||||
parts.append(run.text)
|
||||
return "".join(parts)
|
||||
|
||||
@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 PPTX 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, 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:
|
||||
text = self._convert(io.BytesIO(bytestream.data))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
documents.append(Document(content=text, meta=merged_metadata))
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,228 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from enum import Enum
|
||||
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
|
||||
|
||||
with LazyImport("Run 'pip install pypdf'") as pypdf_import:
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyPDFExtractionMode(Enum):
|
||||
"""
|
||||
The mode to use for extracting text from a PDF.
|
||||
"""
|
||||
|
||||
PLAIN = "plain"
|
||||
LAYOUT = "layout"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Convert a PyPDFExtractionMode enum to a string.
|
||||
"""
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_str(string: str) -> "PyPDFExtractionMode":
|
||||
"""
|
||||
Convert a string to a PyPDFExtractionMode enum.
|
||||
"""
|
||||
enum_map = {e.value: e for e in PyPDFExtractionMode}
|
||||
mode = enum_map.get(string)
|
||||
if mode is None:
|
||||
msg = f"Unknown extraction mode '{string}'. Supported modes are: {list(enum_map.keys())}"
|
||||
raise ValueError(msg)
|
||||
return mode
|
||||
|
||||
|
||||
@component
|
||||
class PyPDFToDocument:
|
||||
"""
|
||||
Converts PDF files to documents your pipeline can query.
|
||||
|
||||
This component uses the PyPDF library.
|
||||
You can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.pypdf import PyPDFToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = PyPDFToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/pdf/sample_pdf_1.pdf"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is a text from the PDF file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
extraction_mode: str | PyPDFExtractionMode = PyPDFExtractionMode.PLAIN,
|
||||
plain_mode_orientations: tuple = (0, 90, 180, 270),
|
||||
plain_mode_space_width: float = 200.0,
|
||||
layout_mode_space_vertically: bool = True,
|
||||
layout_mode_scale_weight: float = 1.25,
|
||||
layout_mode_strip_rotated: bool = True,
|
||||
layout_mode_font_height_weight: float = 1.0,
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Create an PyPDFToDocument component.
|
||||
|
||||
:param extraction_mode:
|
||||
The mode to use for extracting text from a PDF.
|
||||
Layout mode is an experimental mode that adheres to the rendered layout of the PDF.
|
||||
:param plain_mode_orientations:
|
||||
Tuple of orientations to look for when extracting text from a PDF in plain mode.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.LAYOUT`.
|
||||
:param plain_mode_space_width:
|
||||
Forces default space width if not extracted from font.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.LAYOUT`.
|
||||
:param layout_mode_space_vertically:
|
||||
Whether to include blank lines inferred from y distance + font height.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_scale_weight:
|
||||
Multiplier for string length when calculating weighted average character width.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_strip_rotated:
|
||||
Layout mode does not support rotated text. Set to `False` to include rotated text anyway.
|
||||
If rotated text is discovered, layout will be degraded and a warning will be logged.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
:param layout_mode_font_height_weight:
|
||||
Multiplier for font height when calculating blank line height.
|
||||
Ignored if `extraction_mode` is `PyPDFExtractionMode.PLAIN`.
|
||||
: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.
|
||||
"""
|
||||
pypdf_import.check()
|
||||
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
if isinstance(extraction_mode, str):
|
||||
extraction_mode = PyPDFExtractionMode.from_str(extraction_mode)
|
||||
self.extraction_mode = extraction_mode
|
||||
self.plain_mode_orientations = plain_mode_orientations
|
||||
self.plain_mode_space_width = plain_mode_space_width
|
||||
self.layout_mode_space_vertically = layout_mode_space_vertically
|
||||
self.layout_mode_scale_weight = layout_mode_scale_weight
|
||||
self.layout_mode_strip_rotated = layout_mode_strip_rotated
|
||||
self.layout_mode_font_height_weight = layout_mode_font_height_weight
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
extraction_mode=str(self.extraction_mode),
|
||||
plain_mode_orientations=self.plain_mode_orientations,
|
||||
plain_mode_space_width=self.plain_mode_space_width,
|
||||
layout_mode_space_vertically=self.layout_mode_space_vertically,
|
||||
layout_mode_scale_weight=self.layout_mode_scale_weight,
|
||||
layout_mode_strip_rotated=self.layout_mode_strip_rotated,
|
||||
layout_mode_font_height_weight=self.layout_mode_font_height_weight,
|
||||
store_full_path=self.store_full_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PyPDFToDocument":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary with serialized data.
|
||||
|
||||
:returns:
|
||||
Deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
def _default_convert(self, reader: "PdfReader") -> str:
|
||||
texts = []
|
||||
for page in reader.pages:
|
||||
extracted_text = page.extract_text(
|
||||
orientations=self.plain_mode_orientations,
|
||||
extraction_mode=self.extraction_mode.value,
|
||||
space_width=self.plain_mode_space_width,
|
||||
layout_mode_space_vertically=self.layout_mode_space_vertically,
|
||||
layout_mode_scale_weight=self.layout_mode_scale_weight,
|
||||
layout_mode_strip_rotated=self.layout_mode_strip_rotated,
|
||||
layout_mode_font_height_weight=self.layout_mode_font_height_weight,
|
||||
)
|
||||
texts.append(extracted_text)
|
||||
return "\f".join(texts)
|
||||
|
||||
@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, list[Document]]:
|
||||
"""
|
||||
Converts PDF files to documents.
|
||||
|
||||
:param sources:
|
||||
List of file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be 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, its length must match the number of sources, as they are zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of converted documents.
|
||||
"""
|
||||
documents = []
|
||||
meta_list = normalize_metadata(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:
|
||||
pdf_reader = PdfReader(io.BytesIO(bytestream.data))
|
||||
text = self._default_convert(pdf_reader)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to Document, skipping. {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
if text is None or text.strip() == "":
|
||||
logger.warning(
|
||||
"PyPDFToDocument could not extract text from the file {source}. Returning an empty document.",
|
||||
source=source,
|
||||
)
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, logging
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@component
|
||||
class TextFileToDocument:
|
||||
"""
|
||||
Converts text files to documents your pipeline can query.
|
||||
|
||||
By default, it uses UTF-8 encoding when converting files but
|
||||
you can also set custom encoding.
|
||||
It can attach metadata to the resulting documents.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.txt import TextFileToDocument
|
||||
|
||||
converter = TextFileToDocument()
|
||||
results = converter.run(sources=["test/test_files/txt/doc_1.txt"])
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> 'This is the content from the txt file.'
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, encoding: str = "utf-8", store_full_path: bool = False) -> None:
|
||||
"""
|
||||
Creates a TextFileToDocument component.
|
||||
|
||||
:param encoding:
|
||||
The encoding of the text files to convert.
|
||||
If the encoding is specified in the metadata of a source ByteStream,
|
||||
it overrides this value.
|
||||
: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.
|
||||
"""
|
||||
self.encoding = encoding
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@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, list[Document]]:
|
||||
"""
|
||||
Converts text files to documents.
|
||||
|
||||
:param sources:
|
||||
List of text file paths or ByteStream objects to convert.
|
||||
:param meta:
|
||||
Optional metadata to attach to the documents.
|
||||
This value can be 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, its length must match the number of sources as they're zipped together.
|
||||
For ByteStream objects, their `meta` is added to the output documents.
|
||||
|
||||
:returns:
|
||||
A dictionary with the following keys:
|
||||
- `documents`: A list of converted documents.
|
||||
"""
|
||||
documents = []
|
||||
|
||||
meta_list = normalize_metadata(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:
|
||||
encoding = bytestream.meta.get("encoding", self.encoding)
|
||||
text = bytestream.data.decode(encoding)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not convert file {source}. Skipping it. Error message: {error}", source=source, error=e
|
||||
)
|
||||
continue
|
||||
|
||||
merged_metadata = {**bytestream.meta, **metadata}
|
||||
|
||||
if not self.store_full_path and (file_path := bytestream.meta.get("file_path")):
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
document = Document(content=text, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
@@ -0,0 +1,51 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
def get_bytestream_from_source(source: str | Path | ByteStream, guess_mime_type: bool = False) -> ByteStream:
|
||||
"""
|
||||
Creates a ByteStream object from a source.
|
||||
|
||||
:param source:
|
||||
A source to convert to a ByteStream. Can be a string (path to a file), a Path object, or a ByteStream.
|
||||
:param guess_mime_type:
|
||||
Whether to guess the mime type from the file.
|
||||
:return:
|
||||
A ByteStream object.
|
||||
"""
|
||||
|
||||
if isinstance(source, ByteStream):
|
||||
return source
|
||||
if isinstance(source, (str, Path)):
|
||||
bs = ByteStream.from_file_path(Path(source), guess_mime_type=guess_mime_type)
|
||||
bs.meta["file_path"] = str(source)
|
||||
return bs
|
||||
raise ValueError(f"Unsupported source type {type(source)}")
|
||||
|
||||
|
||||
def normalize_metadata(meta: dict[str, Any] | list[dict[str, Any]] | None, sources_count: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize the metadata input for a converter.
|
||||
|
||||
Given all the possible value of the meta input for a converter (None, dictionary or list of dicts),
|
||||
makes sure to return a list of dictionaries of the correct length for the converter to use.
|
||||
|
||||
:param meta: the meta input of the converter, as-is
|
||||
:param sources_count: the number of sources the converter received
|
||||
:returns: a list of dictionaries of the make length as the sources list
|
||||
"""
|
||||
if meta is None:
|
||||
return [{}] * sources_count
|
||||
if isinstance(meta, dict):
|
||||
return [meta] * sources_count
|
||||
if isinstance(meta, list):
|
||||
if sources_count != len(meta):
|
||||
raise ValueError("The length of the metadata list must match the number of sources.")
|
||||
return meta
|
||||
raise ValueError("meta must be either None, a dictionary or a list of dictionaries.")
|
||||
@@ -0,0 +1,236 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
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 pandas openpyxl'") as pandas_xlsx_import:
|
||||
import openpyxl
|
||||
import pandas as pd
|
||||
|
||||
with LazyImport("Run 'pip install tabulate'") as tabulate_import:
|
||||
from tabulate import tabulate # noqa: F401 # the library is used but not directly referenced
|
||||
|
||||
|
||||
@component
|
||||
class XLSXToDocument:
|
||||
"""
|
||||
Converts XLSX (Excel) files into Documents.
|
||||
|
||||
Supports reading data from specific sheets or all sheets in the Excel file. If all sheets are read, a Document is
|
||||
created for each sheet. The content of the Document is the table which can be saved in CSV or Markdown format.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack.components.converters.xlsx import XLSXToDocument
|
||||
from datetime import datetime
|
||||
|
||||
converter = XLSXToDocument()
|
||||
results = converter.run(
|
||||
sources=["test/test_files/xlsx/basic_tables_two_sheets.xlsx"], meta={"date_added": datetime.now().isoformat()}
|
||||
)
|
||||
documents = results["documents"]
|
||||
|
||||
print(documents[0].content)
|
||||
# >> ",A,B\\n1,col_a,col_b\\n2,1.5,test\\n"
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_format: Literal["csv", "markdown"] = "csv",
|
||||
sheet_name: str | int | list[str | int] | None = None,
|
||||
read_excel_kwargs: dict[str, Any] | None = None,
|
||||
table_format_kwargs: dict[str, Any] | None = None,
|
||||
*,
|
||||
link_format: Literal["markdown", "plain", "none"] = "none",
|
||||
store_full_path: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a XLSXToDocument component.
|
||||
|
||||
:param table_format: The format to convert the Excel file to.
|
||||
:param sheet_name: The name of the sheet to read. If None, all sheets are read.
|
||||
:param read_excel_kwargs: Additional arguments to pass to `pandas.read_excel`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.read_excel.html#pandas-read-excel
|
||||
:param table_format_kwargs: Additional keyword arguments to pass to the table format function.
|
||||
- If `table_format` is "csv", these arguments are passed to `pandas.DataFrame.to_csv`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_csv.html#pandas-dataframe-to-csv
|
||||
- If `table_format` is "markdown", these arguments are passed to `pandas.DataFrame.to_markdown`.
|
||||
See https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_markdown.html#pandas-dataframe-to-markdown
|
||||
:param link_format: The format for link output. Possible options:
|
||||
- `"markdown"`: `[text](url)`
|
||||
- `"plain"`: `text (url)`
|
||||
- `"none"`: Only the text is extracted, link addresses are ignored.
|
||||
: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.
|
||||
"""
|
||||
pandas_xlsx_import.check()
|
||||
self.table_format = table_format
|
||||
if table_format not in ["csv", "markdown"]:
|
||||
raise ValueError(f"Unsupported export format: {table_format}. Choose either 'csv' or 'markdown'.")
|
||||
if link_format not in ("markdown", "plain", "none"):
|
||||
msg = f"Unknown link format '{link_format}'. Supported formats are: 'markdown', 'plain', 'none'"
|
||||
raise ValueError(msg)
|
||||
if table_format == "markdown":
|
||||
tabulate_import.check()
|
||||
self.link_format = link_format
|
||||
self.sheet_name = sheet_name
|
||||
self.read_excel_kwargs = read_excel_kwargs or {}
|
||||
self.table_format_kwargs = table_format_kwargs or {}
|
||||
self.store_full_path = store_full_path
|
||||
|
||||
@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, list[Document]]:
|
||||
"""
|
||||
Converts a XLSX file to a Document.
|
||||
|
||||
: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, 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:
|
||||
tables, tables_metadata = self._extract_tables(bytestream)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not read {source} and convert it to a Document, skipping. Error: {error}",
|
||||
source=source,
|
||||
error=e,
|
||||
)
|
||||
continue
|
||||
|
||||
# Loop over tables and create a Document for each table
|
||||
for table, excel_metadata in zip(tables, tables_metadata, strict=True):
|
||||
merged_metadata = {**bytestream.meta, **metadata, **excel_metadata}
|
||||
|
||||
if not self.store_full_path and "file_path" in bytestream.meta:
|
||||
file_path = bytestream.meta["file_path"]
|
||||
merged_metadata["file_path"] = os.path.basename(file_path)
|
||||
|
||||
document = Document(content=table, meta=merged_metadata)
|
||||
documents.append(document)
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
@staticmethod
|
||||
def _generate_excel_column_names(n_cols: int) -> list[str]:
|
||||
result = []
|
||||
for i in range(n_cols):
|
||||
col_name = ""
|
||||
num = i
|
||||
while num >= 0:
|
||||
col_name = chr(num % 26 + 65) + col_name
|
||||
num = num // 26 - 1
|
||||
result.append(col_name)
|
||||
return result
|
||||
|
||||
def _extract_tables(self, bytestream: ByteStream) -> tuple[list[str], list[dict]]:
|
||||
"""
|
||||
Extract tables from an Excel file.
|
||||
"""
|
||||
file_bytes = io.BytesIO(bytestream.data)
|
||||
resolved_read_excel_kwargs = {
|
||||
**self.read_excel_kwargs,
|
||||
"sheet_name": self.sheet_name,
|
||||
"header": None, # Don't assign any pandas column labels
|
||||
"engine": "openpyxl", # Use openpyxl as the engine to read the Excel file
|
||||
}
|
||||
sheet_to_dataframe = pd.read_excel(io=file_bytes, **resolved_read_excel_kwargs)
|
||||
if isinstance(sheet_to_dataframe, pd.DataFrame):
|
||||
sheet_to_dataframe = {self.sheet_name: sheet_to_dataframe}
|
||||
|
||||
# If link extraction is enabled, load the workbook with openpyxl to read hyperlinks
|
||||
hyperlinks_by_sheet: dict[str | int | None, dict[tuple[int, int], str]] = {}
|
||||
if self.link_format != "none":
|
||||
file_bytes.seek(0)
|
||||
wb = openpyxl.load_workbook(file_bytes, data_only=True)
|
||||
for sheet_key in sheet_to_dataframe:
|
||||
if isinstance(sheet_key, int):
|
||||
ws = wb.worksheets[sheet_key]
|
||||
elif sheet_key is None:
|
||||
ws = wb.active
|
||||
else:
|
||||
ws = wb[sheet_key]
|
||||
cell_links: dict[tuple[int, int], str] = {}
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.hyperlink and cell.hyperlink.target:
|
||||
# Convert to 0-based indices to match DataFrame positions
|
||||
cell_links[(cell.row - 1, cell.column - 1)] = cell.hyperlink.target
|
||||
hyperlinks_by_sheet[sheet_key] = cell_links
|
||||
wb.close()
|
||||
|
||||
updated_sheet_to_dataframe = {}
|
||||
for key in sheet_to_dataframe:
|
||||
df = sheet_to_dataframe[key]
|
||||
# Row starts at 1 in Excel
|
||||
df.index = df.index + 1
|
||||
# Excel column names are Alphabet Characters
|
||||
header = self._generate_excel_column_names(df.shape[1])
|
||||
df.columns = header
|
||||
|
||||
# Apply hyperlinks to cell values
|
||||
if key in hyperlinks_by_sheet:
|
||||
for (row_idx, col_idx), url in hyperlinks_by_sheet[key].items():
|
||||
if row_idx < len(df) and col_idx < len(df.columns):
|
||||
cell_value = df.iat[row_idx, col_idx]
|
||||
text = str(cell_value) if pd.notna(cell_value) else ""
|
||||
if self.link_format == "markdown":
|
||||
df.iat[row_idx, col_idx] = f"[{text}]({url})"
|
||||
else:
|
||||
df.iat[row_idx, col_idx] = f"{text} ({url})"
|
||||
|
||||
updated_sheet_to_dataframe[key] = df
|
||||
|
||||
tables = []
|
||||
metadata = []
|
||||
for key, value in updated_sheet_to_dataframe.items():
|
||||
if self.table_format == "csv":
|
||||
resolved_kwargs = {"index": True, "header": True, "lineterminator": "\n", **self.table_format_kwargs}
|
||||
tables.append(value.to_csv(**resolved_kwargs))
|
||||
else:
|
||||
resolved_kwargs = {
|
||||
"index": True,
|
||||
"headers": value.columns,
|
||||
"tablefmt": "pipe",
|
||||
**self.table_format_kwargs,
|
||||
}
|
||||
# to_markdown uses tabulate
|
||||
tables.append(value.to_markdown(**resolved_kwargs))
|
||||
# add sheet_name to metadata
|
||||
metadata.append({"xlsx": {"sheet_name": key}})
|
||||
return tables, metadata
|
||||
Reference in New Issue
Block a user