chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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}
|
||||
Reference in New Issue
Block a user