From 5a9b6cd06d682b72564d3f2bcd54dac4cf00c696 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:45:40 +0200 Subject: [PATCH] fix: PDF upload failures with PDF Extract Images (OCR) enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading a PDF with OCR image extraction enabled fails with "cannot reshape array" or similar errors whenever a page contains an image the bundled langchain parser cannot decode: filter chains like [/ASCII85Decode /DCTDecode], images without a /Filter entry, 1-bit scans, image masks, indexed and grayscale images. One such image aborts the whole upload. Reported since v0.5.7 (#4457, #6085, #8856, #10913, #11333, #25979). That parser reimplements PDF filter handling by hand and assumes a single lossless filter. langchain-community is archived and will not take a patch, so a small parser subclass now delegates image decoding to pypdf, which handles all of these cases, and an image that still cannot be read is skipped with a warning instead of failing the file. Verified against the stock loader on fixture PDFs: previously working files produce identical output. Further differences, all deliberate: images nested in Form XObjects are now found; inline images stay excluded like before, which also avoids pypdf retaining every decoded inline image per page (115 MB on a 106 KB test file); image lookups sidestep pypdf's quadratic resource-tree re-walk (93 s down to 0.4 s on a 136 KB crafted file); CMYK JPEGs reach OCR with correct colours where pypdf's unconditional inversion turned them solid black. Fixes #29187 ### Contributor License Agreement - [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. > [!NOTE] > Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in. --- backend/open_webui/retrieval/loaders/main.py | 4 +- backend/open_webui/retrieval/loaders/pdf.py | 70 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 backend/open_webui/retrieval/loaders/pdf.py diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index f84b2a2b1b..1a5185d02a 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -13,7 +13,6 @@ from langchain_community.document_loaders import ( BSHTMLLoader, CSVLoader, Docx2txtLoader, - PyPDFLoader, TextLoader, ) from langchain_core.documents import Document @@ -28,6 +27,7 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mistral import MistralLoader from open_webui.retrieval.loaders.paddleocr_vl import PADDLEOCR_VL_SUPPORTED_EXTENSIONS, PaddleOCRVLLoader +from open_webui.retrieval.loaders.pdf import PyPDFLoaderWithSafeImages from open_webui.utils.headers import get_user_groups_for_custom_headers from open_webui.utils.json_codec import JSONCodec @@ -667,7 +667,7 @@ class Loader: ) else: if file_ext == 'pdf': - loader = PyPDFLoader( + loader = PyPDFLoaderWithSafeImages( file_path, extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'), mode=self.kwargs.get('PDF_LOADER_MODE', 'page'), diff --git a/backend/open_webui/retrieval/loaders/pdf.py b/backend/open_webui/retrieval/loaders/pdf.py new file mode 100644 index 0000000000..0dc2a22133 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/pdf.py @@ -0,0 +1,70 @@ +import io +import logging +from typing import Literal + +from langchain_community.document_loaders import PyPDFLoader +from langchain_community.document_loaders.parsers.pdf import PyPDFParser +from langchain_core.document_loaders import Blob +from PIL import Image +from pypdf import PageObject +from pypdf.generic import StreamObject + +log = logging.getLogger(__name__) + + +def _is_cmyk_jpeg(image_stream: StreamObject) -> bool: + """True when the image is a CMYK JPEG whose data pypdf will hand back inverted.""" + filters = image_stream['/Filter'] if '/Filter' in image_stream else None + if (filters[-1] if isinstance(filters, list) else filters) != '/DCTDecode': + return False + return Image.open(io.BytesIO(image_stream.get_data())).mode == 'CMYK' + + +class PyPDFParserWithSafeImages(PyPDFParser): + """PyPDFParser that lets pypdf decode page images instead of reading /Filter itself.""" + + def extract_images_from_page(self, page: PageObject) -> str: + if not self.images_parser: + return '' + + page.inline_images = {} + try: + images = page.images[:] + except Exception as e: + log.warning('Skipping images on PDF page: %s', e) + return '' + + image_texts = [] + for index in range(len(images)): + try: + image_file = images[index] + image_stream = image_file.indirect_reference.get_object() + image = image_file.image + # pypdf re-inverts CMYK JPEGs that PIL already un-inverted, and an /SMask makes that unrecoverable + if _is_cmyk_jpeg(image_stream): + image = Image.open(io.BytesIO(image_stream.get_data())) + + png_bytes = io.BytesIO() + image.convert('RGB').save(png_bytes, format='PNG') + except Exception as e: + log.warning('Skipping unreadable image %s on PDF page: %s', index, e) + continue + + blob = Blob.from_data(png_bytes.getvalue(), mime_type='image/png') + text = next(self.images_parser.lazy_parse(blob)).page_content + if text: + image_texts.append(text) + + if not image_texts: + return '' + return '\n\n' + '\n'.join(image_texts) + '\n\n' + + +class PyPDFLoaderWithSafeImages(PyPDFLoader): + """Loads a PDF with pypdf, tolerating images it cannot decode instead of failing the document.""" + + def __init__( + self, file_path: str, *, extract_images: bool = False, mode: Literal['single', 'page'] = 'page' + ) -> None: + super().__init__(file_path, extract_images=extract_images, mode=mode) + self.parser = PyPDFParserWithSafeImages(extract_images=extract_images, mode=mode)