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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
@@ -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)
+238
View File
@@ -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)
+410
View File
@@ -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}
+147
View File
@@ -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}
+289
View File
@@ -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}
+180
View File
@@ -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
+192
View File
@@ -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)
+228
View File
@@ -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}
+158
View File
@@ -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}
+228
View File
@@ -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}
+100
View File
@@ -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}
+51
View File
@@ -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.")
+236
View File
@@ -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