diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e971aee40..d1769a46df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.26.1 + +### Fixes + +- **Bound array-stream decoding in `is_pdf_too_complex` (SEC-146)**: fixes a quadratic `bytes +=` accumulation over array-based `/Contents` (CVE-2026-33123) that let a crafted PDF spike CPU/memory. Accumulation now uses a `bytearray`, with per-page and document-wide caps on decoded bytes and array entries that fail closed on crafted content, and every file is inspected by default (small files are no longer skipped, since a small compressed file can declare huge content). Indirect `/Contents` arrays are now dereferenced (they were being skipped), a single unreadable stream no longer skips the rest of its page, and operator counting no longer allocates a full match list. Bumps `pypdf` to `>=6.9.1` so its own patched code path is used. + ## 0.26.0 ### Fixes diff --git a/pyproject.toml b/pyproject.toml index d2a53c058b..497fa89b21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ image = [ "pdfminer.six>=20251230, <20270000", "pi-heif>=1.2.0, <2.0.0", "pikepdf>=10.3.0, <11.0.0", - "pypdf>=6.6.2, <7.0.0", + "pypdf>=6.9.1, <7.0.0", "unstructured-inference>=1.6.12, <2.0.0; platform_system != 'Windows'", "unstructured-inference>=1.6.12, <2.0.0; platform_system == 'Windows' and python_version >= '3.12' and python_version < '3.13'", "unstructured-pytesseract>=0.3.15, <1.0.0", diff --git a/test_unstructured/partition/pdf_image/test_pdf.py b/test_unstructured/partition/pdf_image/test_pdf.py index bde2f4245c..6b7b640d45 100644 --- a/test_unstructured/partition/pdf_image/test_pdf.py +++ b/test_unstructured/partition/pdf_image/test_pdf.py @@ -6,6 +6,8 @@ import math import os import tempfile +import time +import zlib from dataclasses import dataclass from importlib import reload from pathlib import Path @@ -16,6 +18,9 @@ import pytest from pdf2image.exceptions import PDFPageCountError from PIL import Image +from pypdf import PdfWriter +from pypdf.errors import LimitReachedError +from pypdf.generic import ArrayObject, DecodedStreamObject, NameObject, NullObject from pytest_mock import MockFixture from unstructured_inference.inference import layout, pdf_image from unstructured_inference.inference.elements import Rectangle @@ -1698,6 +1703,26 @@ def test_is_pdf_too_complex_skips_small_file_size(): assert not pdf.is_pdf_too_complex(file=b"tiny", min_file_size_bytes=10) +def test_is_pdf_too_complex_inspects_small_files_by_default(): + """A small compressed file can still declare huge decoded content, so the default + (min_file_size_bytes=0) must inspect it rather than skip on file size.""" + + # One 100 KB stream referenced 9,000 times: a ~55 KB file, ~900 MB nominal decoded. + stream = DecodedStreamObject() + stream[NameObject("/Filter")] = NameObject("/FlateDecode") + stream._data = zlib.compress(b"\x00" * 100_000) + writer = PdfWriter() + writer.add_blank_page(width=200, height=200) + ref = writer._add_object(stream) + writer.pages[0][NameObject("/Contents")] = ArrayObject([ref for _ in range(9_000)]) + buffer = io.BytesIO() + writer.write(buffer) + data = buffer.getvalue() + + assert len(data) < 1024 * 1024 # under the old 1 MB skip threshold + assert pdf.is_pdf_too_complex(file=data) # defaults; no min_file_size_bytes override + + def test_is_pdf_too_complex_detects_vector_heavy_page(): class MockStream: def get_data(self): @@ -1767,6 +1792,288 @@ def test_is_pdf_too_complex_returns_false_for_normal_pdf(): assert not pdf.is_pdf_too_complex(filename=example_doc_path("pdf/layout-parser-paper.pdf")) +def _pdf_with_content_stream_array( + per_stream_payload: bytes, + num_streams: int, + *, + indirect_array: bool = False, +) -> bytes: + """One-page PDF whose ``/Contents`` is an array of ``num_streams`` FlateDecode streams + (small file, huge decoded output -- CVE-2026-33123). pypdf private API keeps it small.""" + writer = PdfWriter() + writer.add_blank_page(width=200, height=200) + compressed = zlib.compress(per_stream_payload) + + refs = ArrayObject() + for _ in range(num_streams): + stream = DecodedStreamObject() + stream[NameObject("/Filter")] = NameObject("/FlateDecode") + stream._data = compressed + refs.append(writer._add_object(stream)) + + contents = writer._add_object(refs) if indirect_array else refs + writer.pages[0][NameObject("/Contents")] = contents + + buffer = io.BytesIO() + writer.write(buffer) + return buffer.getvalue() + + +@pytest.mark.parametrize("indirect_array", [False, True], ids=["direct", "indirect"]) +def test_is_pdf_too_complex_flags_graphics_heavy_content_array(indirect_array): + """A direct or indirect array of graphics-heavy streams is flagged too complex. + The indirect case guards the dereference: it used to skip the array branch.""" + + payload = b" ".join([b"m"] * 400 + [b"Tj"] * 2) # graphics-heavy, ratio 200:1 + data = _pdf_with_content_stream_array(payload, num_streams=300, indirect_array=indirect_array) + + assert pdf.is_pdf_too_complex( + file=data, + max_graphics_ops=100, + min_graphics_to_text_ratio=20.0, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + ) + + +def test_is_pdf_too_complex_bounds_array_of_many_streams(): + """CVE-2026-33123 regression: a ~2 MB file whose array decodes to ~900 MB ran for + minutes / OOM'd on the old `bytes +=`; the fix caps it and returns almost at once.""" + + data = _pdf_with_content_stream_array(b"\x00" * 100_000, num_streams=9_000) + assert len(data) < 10 * 1024 * 1024 # small file, huge nominal decoded size + + start = time.perf_counter() + result = pdf.is_pdf_too_complex(file=data, min_file_size_bytes=1, min_raw_stream_bytes=1) + elapsed = time.perf_counter() - start + + # Decoded content blows past the 50 MB per-page cap, so the page fails closed. + assert result is True + assert elapsed < 10.0, f"is_pdf_too_complex took {elapsed:.2f}s -- accumulation is not bounded" + + +def test_is_pdf_too_complex_caps_content_array_entries(): + """An array of many empty/tiny streams cannot force unbounded work: the entry-count + cap short-circuits before any stream is decoded, and the page fails closed.""" + + call_count = 0 + + class EmptyStream: + def get_data(self): + nonlocal call_count + call_count += 1 + return b"" + + num_streams = 50_000 + contents = ArrayObject([EmptyStream() for _ in range(num_streams)]) + + reader = mock.Mock() + reader.pages = [{"/Contents": contents}] + + with mock.patch.object(pdf, "PdfReader", return_value=reader): + result = pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_content_stream_array_entries=10_000, + ) + + assert result is True + assert call_count == 0 # cap checked against len(contents) before any decode + + +def test_is_pdf_too_complex_caps_oversized_stream_before_copy(): + """A stream over the byte cap fails closed before being copied/scanned, in both the + array and standalone branches.""" + + class BigStream: + def get_data(self): + return b"a" * 25_000 + + # Array branch: oversized entry trips the cap on the first item. + array_stream = BigStream() + reader = mock.Mock() + reader.pages = [{"/Contents": ArrayObject([array_stream, BigStream(), BigStream()])}] + with mock.patch.object(pdf, "PdfReader", return_value=reader): + assert pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_raw_stream_bytes=10_000, + ) + + # Standalone (non-array) branch: oversized single stream also fails closed. + single_stream = BigStream() + reader.pages = [{"/Contents": single_stream}] + with mock.patch.object(pdf, "PdfReader", return_value=reader): + assert pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_raw_stream_bytes=10_000, + ) + + +def test_is_pdf_too_complex_bounds_total_bytes_across_pages(): + """Pages sharing one array stay under the per-page cap, so only the document byte + budget can fail them closed once the decoded total exceeds it.""" + + # Graphics-light filler so no page trips the ratio -- the byte budget must be what fails. + payload = b"\x00" * 40_000 # 40 KB per page, well under the per-page cap + stream = DecodedStreamObject() + stream[NameObject("/Filter")] = NameObject("/FlateDecode") + stream._data = zlib.compress(payload) + + writer = PdfWriter() + shared_ref = writer._add_object(stream) # one stream, referenced by every page + for _ in range(10): + writer.add_blank_page(width=200, height=200) + writer.pages[-1][NameObject("/Contents")] = ArrayObject([shared_ref]) + buffer = io.BytesIO() + writer.write(buffer) + data = buffer.getvalue() + + # 250 KB budget below the 400 KB document total -> fail closed. + assert pdf.is_pdf_too_complex( + file=data, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_raw_stream_bytes=1_000_000, + max_total_stream_bytes=250_000, + ) + + +def test_is_pdf_too_complex_document_budget_survives_decode_errors(): + """Bytes are charged per decoded stream, so a `[valid, raises]` array still counts + the valid stream -- a mid-page decode error can't discard the accounting.""" + + good = DecodedStreamObject() + good[NameObject("/Filter")] = NameObject("/FlateDecode") + good._data = zlib.compress(b"\x00" * 90_000) # decodes to 90 KB + + bad = DecodedStreamObject() + bad[NameObject("/Filter")] = NameObject("/UnknownBogusFilter") # get_data() raises + bad._data = b"garbage" + + writer = PdfWriter() + good_ref = writer._add_object(good) + bad_ref = writer._add_object(bad) + shared_ref = writer._add_object(ArrayObject([good_ref, bad_ref])) + for _ in range(10): + writer.add_blank_page(width=200, height=200) + writer.pages[-1][NameObject("/Contents")] = shared_ref + buffer = io.BytesIO() + writer.write(buffer) + data = buffer.getvalue() + + # Two valid 90 KB streams exceed the 150 KB budget despite each page's second raising. + assert pdf.is_pdf_too_complex( + file=data, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_raw_stream_bytes=100_000, + max_total_stream_bytes=150_000, + ) + + +@pytest.mark.parametrize("array_contents", [False, True], ids=["standalone", "array"]) +def test_is_pdf_too_complex_fails_closed_on_decoder_limit(array_contents): + """A stream that raises LimitReachedError (pypdf's decode-limit, e.g. a compression + bomb) fails the page closed rather than being skipped and handed to PDFMiner.""" + + class BombStream: + def get_data(self): + raise LimitReachedError("Limit reached while decompressing.") + + contents = ArrayObject([BombStream()]) if array_contents else BombStream() + reader = mock.Mock() + reader.pages = [{"/Contents": contents}] + + with mock.patch.object(pdf, "PdfReader", return_value=reader): + assert pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + ) + + +def test_is_pdf_too_complex_unreadable_stream_does_not_skip_rest_of_page(): + """One stream that fails to decode skips only itself; the remaining streams on the + page are still inspected, so a bad sibling can't mask an over-cap stream.""" + + class BadStream: + def get_data(self): + raise NotImplementedError("unsupported filter") + + class ValidStream: + def get_data(self): + return b"a" * 20_000 # over the 10 KB cap below + + reader = mock.Mock() + reader.pages = [{"/Contents": ArrayObject([BadStream(), ValidStream()])}] + + with mock.patch.object(pdf, "PdfReader", return_value=reader): + # Old behavior skipped the whole page on the bad stream and returned False. + assert pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_raw_stream_bytes=10_000, + ) + + +def test_is_pdf_too_complex_bounds_total_entries_across_pages(): + """The document entry budget bounds total streams decoded, so empty streams (which + never move the byte budget) can't scale work with page count.""" + + get_data_calls = 0 + + class EmptyStream: + def get_data(self): + nonlocal get_data_calls + get_data_calls += 1 + return b"" + + # One 1,000-entry array (under the 10,000 per-page cap) shared by every page. + shared = ArrayObject([EmptyStream() for _ in range(1_000)]) + reader = mock.Mock() + reader.pages = [{"/Contents": shared} for _ in range(100)] + + with mock.patch.object(pdf, "PdfReader", return_value=reader): + result = pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_content_stream_array_entries=10_000, # per-page cap NOT hit (1,000 < 10,000) + max_total_array_entries=5_000, + ) + + assert result is True # fail closed once the entry budget is exhausted + assert get_data_calls == 5_000 # bounded by the budget, not the 100k possible decodes + + +def test_is_pdf_too_complex_charges_non_stream_entries_to_budget(): + """The entry budget charges every slot, so a shared array of non-stream objects + (nulls) is bounded too -- charging only streams left traversal scaling with pages.""" + + # 9,999 non-stream entries (under the 10,000 per-page cap), shared across many pages. + shared = ArrayObject([NullObject() for _ in range(9_999)]) + reader = mock.Mock() + reader.pages = [{"/Contents": shared} for _ in range(200)] + + with mock.patch.object(pdf, "PdfReader", return_value=reader): + result = pdf.is_pdf_too_complex( + file=b"x" * 20, + min_file_size_bytes=1, + min_raw_stream_bytes=1, + max_content_stream_array_entries=10_000, # per-page cap NOT hit (9,999 < 10,000) + max_total_array_entries=1, + ) + + # Fails closed on page 1; the pre-fix code charged only streams and returned False. + assert result is True + + def test_document_to_element_list_omits_coord_system_when_coord_points_absent(): # TODO (yao): investigate why we need this test. The LayoutElement definition suggests bbox # can't be None and it has to be a Rectangle object that has x1, y1, x2, y2 attributes. diff --git a/unstructured/__version__.py b/unstructured/__version__.py index dc57acc0c1..5335d1e044 100644 --- a/unstructured/__version__.py +++ b/unstructured/__version__.py @@ -1 +1 @@ -__version__ = "0.26.0" # pragma: no cover +__version__ = "0.26.1" # pragma: no cover diff --git a/unstructured/partition/pdf.py b/unstructured/partition/pdf.py index 2eb2597a24..69288486fc 100644 --- a/unstructured/partition/pdf.py +++ b/unstructured/partition/pdf.py @@ -16,6 +16,7 @@ from pi_heif import register_heif_opener from PIL import Image as PILImage from pypdf import PdfReader +from pypdf.errors import LimitReachedError from pypdf.generic import ArrayObject, IndirectObject from unstructured.chunking import add_chunking_strategy @@ -104,8 +105,18 @@ rb"(?:^|(?<=\s))" rb"(?:Tj|TJ|'|\"|Tf|Td|TD|Tm|T\*|BT|ET)" rb"(?=\s|$)", re.MULTILINE, ) -DEFAULT_MIN_FILE_SIZE_BYTES = 1 * 1024 * 1024 # 1 MB +# 0 -> inspect every file. A small compressed file can still declare huge decoded +# content (CVE-2026-33123), so skipping small files is opt-in, not the default. +DEFAULT_MIN_FILE_SIZE_BYTES = 0 DEFAULT_MIN_RAW_STREAM_BYTES = 100_000 # 100 KB +# Per-page defense-in-depth caps against crafted content streams (CVE-2026-33123): +# a page exceeding either is treated as too complex (fail closed) instead of scanned. +DEFAULT_MAX_RAW_STREAM_BYTES = 50 * 1024 * 1024 # 50 MB decoded bytes per page +DEFAULT_MAX_CONTENT_STREAM_ARRAY_ENTRIES = 10_000 # array entries per page (pypdf's cap) +# Document-wide caps so total work is bounded by the function, not the page count (pages +# can share one array). Set far above any real document; exceeding them logs at warning. +DEFAULT_MAX_TOTAL_STREAM_BYTES = 1024 * 1024 * 1024 # 1 GB decoded bytes per document +DEFAULT_MAX_TOTAL_ARRAY_ENTRIES = 1_000_000 # array entries decoded per document # increase the max pixels so high dpi values like 300 can still be under the PIL limit PILImage.MAX_IMAGE_PIXELS = 5e8 @@ -622,6 +633,10 @@ def is_pdf_too_complex( min_graphics_to_text_ratio: float = 20.0, min_file_size_bytes: int = DEFAULT_MIN_FILE_SIZE_BYTES, min_raw_stream_bytes: int = DEFAULT_MIN_RAW_STREAM_BYTES, + max_raw_stream_bytes: int = DEFAULT_MAX_RAW_STREAM_BYTES, + max_content_stream_array_entries: int = DEFAULT_MAX_CONTENT_STREAM_ARRAY_ENTRIES, + max_total_stream_bytes: int = DEFAULT_MAX_TOTAL_STREAM_BYTES, + max_total_array_entries: int = DEFAULT_MAX_TOTAL_ARRAY_ENTRIES, ) -> bool: """Check if a PDF is likely a complex vector drawing (e.g., CAD/engineering docs) that would be extremely slow or produce garbage results with PDFMiner text extraction. @@ -632,8 +647,9 @@ def is_pdf_too_complex( decoded stream is smaller than min_raw_stream_bytes. 3. For large streams, regex to count graphics without parsing the stream. - A page is flagged as too complex when it has a high number of graphics operators - AND a high ratio of graphics-to-text operators. + A page is flagged (returns True) on a high graphics-op count AND graphics-to-text + ratio, or, as defense-in-depth against crafted content streams (CVE-2026-33123), + when it exceeds any of the ``max_*`` byte/entry caps below. Parameters ---------- @@ -648,11 +664,25 @@ def is_pdf_too_complex( Minimum ratio of graphics ops to text ops required (in conjunction with `max_graphics_ops`) to flag a page as too complex. min_file_size_bytes - Skip the complexity check entirely for files smaller than this (default 1 MB). + Skip the check entirely for files smaller than this. Default 0 (inspect every + file); raising it trades safety for speed, since a small compressed file can + still declare huge decoded content. min_raw_stream_bytes Skip operator counting for pages whose decoded content stream is smaller than this (default 100 KB). Small streams can't have enough operators to trigger the threshold. + max_raw_stream_bytes + Per-page decoded-byte cap (default 50 MB); a page over it is flagged too + complex instead of scanned in full. + max_content_stream_array_entries + Per-page cap on ``/Contents`` array entries (default 10,000, matching pypdf); + bounds an array of many empty streams. + max_total_stream_bytes + Document-wide decoded-byte cap (default 1 GB), so shared arrays can't scale work + with page count. Set far above any real document; exceeding it logs at warning. + max_total_array_entries + Document-wide cap on array entries traversed (default 1,000,000), charged for + every slot so non-stream entries count too. Exceeding it logs at warning. """ original_pos: Optional[int] = None @@ -691,40 +721,124 @@ def is_pdf_too_complex( if not reader.pages: return False + total_raw_bytes = 0 + total_array_entries = 0 for page_index, page in enumerate(reader.pages): contents = page.get("/Contents") if contents is None: continue - # Decode raw stream bytes (cheap relative to full ContentStream parsing) - raw_data = b"" + # DictionaryObject.get (unlike __getitem__) does not dereference, so an + # indirect /Contents array would otherwise skip the array branch below. try: - if isinstance(contents, ArrayObject): - for item in contents: - obj = item.get_object() if isinstance(item, IndirectObject) else item - if hasattr(obj, "get_data"): - raw_data += obj.get_data() - else: - obj = ( - contents.get_object() if isinstance(contents, IndirectObject) else contents - ) - if hasattr(obj, "get_data"): - raw_data = obj.get_data() + if hasattr(contents, "get_object"): + contents = contents.get_object() except Exception: continue + # Decode raw stream bytes (cheap relative to full ContentStream parsing). + raw_data: Union[bytes, bytearray] = b"" + if isinstance(contents, ArrayObject): + # An array of many small streams is the crafted DoS shape + # (CVE-2026-33123); bound both entry count and decoded bytes. + if len(contents) > max_content_stream_array_entries: + logger.info( + f"Page {page_index + 1} /Contents array has {len(contents)} " + f"entries, exceeding the limit of " + f"{max_content_stream_array_entries}. " + "Flagging PDF as too complex for text extraction." + ) + return True + # Charge every slot up front (non-stream entries are traversed too), + # so a shared non-stream array can't scale traversal with page count. + total_array_entries += len(contents) + if total_array_entries > max_total_array_entries: + logger.warning( + f"Content-stream array entries exceed {max_total_array_entries} " + f"by page {page_index + 1}. " + "Flagging PDF as too complex for text extraction." + ) + return True + # bytearray append is amortized O(1); `bytes +=` was O(n^2). + accumulated = bytearray() + for item in contents: + # Decode each stream in its own try: a bomb fails closed, but an + # otherwise-unreadable stream only skips itself, so the remaining + # streams on the page are still inspected and charged. + try: + obj = item.get_object() if isinstance(item, IndirectObject) else item + if not hasattr(obj, "get_data"): + continue + chunk = obj.get_data() + except LimitReachedError: + logger.warning( + f"Page {page_index + 1} content stream exceeds pypdf's decode " + "limit. Flagging PDF as too complex for text extraction." + ) + return True + except Exception: + continue + total_raw_bytes += len(chunk) + if total_raw_bytes > max_total_stream_bytes: + logger.warning( + f"Decoded content streams exceed {max_total_stream_bytes} " + f"bytes by page {page_index + 1}. " + "Flagging PDF as too complex for text extraction." + ) + return True + # Check before copying so an oversized stream is never + # accumulated into the buffer or regex-scanned. + if len(accumulated) + len(chunk) > max_raw_stream_bytes: + logger.info( + f"Page {page_index + 1} content stream exceeds " + f"{max_raw_stream_bytes} bytes. " + "Flagging PDF as too complex for text extraction." + ) + return True + accumulated.extend(chunk) + raw_data = accumulated + elif hasattr(contents, "get_data"): + try: + chunk = contents.get_data() + except LimitReachedError: + logger.warning( + f"Page {page_index + 1} content stream exceeds pypdf's decode " + "limit. Flagging PDF as too complex for text extraction." + ) + return True + except Exception: + continue + total_raw_bytes += len(chunk) + if total_raw_bytes > max_total_stream_bytes: + logger.warning( + f"Decoded content streams exceed {max_total_stream_bytes} " + f"bytes by page {page_index + 1}. " + "Flagging PDF as too complex for text extraction." + ) + return True + if len(chunk) > max_raw_stream_bytes: + logger.info( + f"Page {page_index + 1} content stream exceeds " + f"{max_raw_stream_bytes} bytes. " + "Flagging PDF as too complex for text extraction." + ) + return True + # No copy: the regexes accept bytes and this is not mutated. + raw_data = chunk + # Skip pages with small content streams if len(raw_data) < min_raw_stream_bytes: continue - # Regex count graphics and text operators without fully parsing the stream - num_graphics_ops = len(GRAPHICS_OPS_PATTERN.findall(raw_data)) + # Count operators via finditer (not findall) to avoid allocating a match + # list proportional to operator density. + num_graphics_ops = sum(1 for _ in GRAPHICS_OPS_PATTERN.finditer(raw_data)) # Early exit: if graphics ops don't even reach threshold, skip text counting if num_graphics_ops <= max_graphics_ops: continue - num_text_ops = len(TEXT_OPS_PATTERN.findall(raw_data)) + num_text_ops = sum(1 for _ in TEXT_OPS_PATTERN.finditer(raw_data)) ratio = num_graphics_ops / max(num_text_ops, 1) if ratio > min_graphics_to_text_ratio: diff --git a/uv.lock b/uv.lock index fa4c7ac65e..35e5e0dc81 100644 --- a/uv.lock +++ b/uv.lock @@ -7468,10 +7468,10 @@ requires-dist = [ { name = "pypandoc-binary", marker = "sys_platform != 'win32' and extra == 'org'", specifier = ">=1.16.2,<2.0.0" }, { name = "pypandoc-binary", marker = "sys_platform != 'win32' and extra == 'rst'", specifier = ">=1.16.2,<2.0.0" }, { name = "pypandoc-binary", marker = "sys_platform != 'win32' and extra == 'rtf'", specifier = ">=1.16.2,<2.0.0" }, - { name = "pypdf", marker = "extra == 'all-docs'", specifier = ">=6.6.2,<7.0.0" }, - { name = "pypdf", marker = "extra == 'image'", specifier = ">=6.6.2,<7.0.0" }, - { name = "pypdf", marker = "extra == 'local-inference'", specifier = ">=6.6.2,<7.0.0" }, - { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=6.6.2,<7.0.0" }, + { name = "pypdf", marker = "extra == 'all-docs'", specifier = ">=6.9.1,<7.0.0" }, + { name = "pypdf", marker = "extra == 'image'", specifier = ">=6.9.1,<7.0.0" }, + { name = "pypdf", marker = "extra == 'local-inference'", specifier = ">=6.9.1,<7.0.0" }, + { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=6.9.1,<7.0.0" }, { name = "python-docx", marker = "extra == 'all-docs'", specifier = ">=1.2.0,<2.0.0" }, { name = "python-docx", marker = "extra == 'doc'", specifier = ">=1.2.0,<2.0.0" }, { name = "python-docx", marker = "extra == 'docx'", specifier = ">=1.2.0,<2.0.0" },