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,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}