fix: PDF upload failures with PDF Extract Images (OCR) enabled

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

<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.

Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->

- [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.
This commit is contained in:
Classic298 2026-09-05 17:45:40 +02:00
parent a93c508038
commit 5a9b6cd06d
2 changed files with 72 additions and 2 deletions

View file

@ -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'),

View file

@ -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)