Fix mypy pikepdf Object typing errors in pdfinfo/optimize/graft

Resolves the remaining 22 pikepdf-related mypy errors in
pdfinfo/_image.py, pdfinfo/info.py, optimize.py, and _graft.py
(89 -> 27 errors remaining, all in the concurrency/fpdf_renderer
clusters).

Adds pikepdf_get_int()/pikepdf_get_bool() to helpers.py: safe
accessors for dict.get(key, default) results, whose static type is
the ambiguous `Object | int`/`Object | bool` and doesn't support
arithmetic/comparison against a plain int/bool.

Important correctness fix caught by the test suite: pikepdf only
returns pikepdf.Object wrappers under explicit_conversion() mode,
which this codebase never enables. By default (implicit mode), PDF
Integers/Booleans are already unboxed to native int/bool by the time
callers see them, so calling the `.as_int()`/`.as_bool()`/
`.as_decimal()` safe accessors unconditionally crashes with
AttributeError on the native-type case (test_oversized_page caught
this for UserUnit). Fixed by using int()/float() builtins, which work
polymorphically on both native numbers and pikepdf.Object (bool()
does not, so pikepdf_get_bool checks isinstance first).

Also:
- pdfinfo/info.py: pass page.obj (an Object) to
  _process_content_streams() instead of page (a Page wrapper, not an
  Object subtype).
- pdfinfo/_image.py: cast() around Matrix(array_object) - pikepdf's
  stub omits the Object/Array constructor overload that the C++
  implementation actually supports.
- optimize.py: cast() around Object.write()'s filter/decode_parms
  args for the same reason.
- _graft.py: iterate parse_content_stream() via .operands/.operator
  instead of tuple-unpacking, since ContentStreamInstruction supports
  the legacy __getitem__-based iteration protocol but not __iter__,
  which mypy doesn't statically recognize.
- pdfinfo/_worker.py: _pdf_pageinfo_concurrent's return type was
  Sequence[PageInfo | None] but always returns a real list; narrowed
  to match, fixing PdfInfo.pages' declared list[...] return type.
This commit is contained in:
James R. Barlow
2026-07-07 00:44:56 -07:00
parent dfdb32995e
commit 212b28e602
6 changed files with 87 additions and 26 deletions
+14 -4
View File
@@ -6,11 +6,12 @@
from __future__ import annotations
import logging
from collections.abc import Collection
from contextlib import suppress
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
@@ -18,6 +19,7 @@ if TYPE_CHECKING:
from pikepdf import (
Dictionary,
Name,
Object,
Operator,
Page,
Pdf,
@@ -181,9 +183,13 @@ def strip_invisible_text(pdf: Pdf, page: Page):
render_mode_stack = []
text_objects = []
for operands, operator in parse_content_stream(page, ''):
for instruction in parse_content_stream(page, ''):
operands, operator = instruction.operands, instruction.operator
if operator == Operator('Tr'):
render_mode = operands[0]
# operands[0] is already a plain int under pikepdf's default
# (implicit) conversion mode, or a pikepdf.Object under explicit
# conversion mode; int() handles both.
render_mode = int(operands[0])
if operator == Operator('q'):
render_mode_stack.append(render_mode)
@@ -207,7 +213,11 @@ def strip_invisible_text(pdf: Pdf, page: Page):
stream.extend(text_objects)
text_objects.clear()
content_stream = unparse_content_stream(stream)
# pikepdf's Collection[...] parameter doesn't structurally match our
# _ObjectList-based tuples even though it works fine at runtime.
content_stream = unparse_content_stream(
cast('list[tuple[Collection[Object], Operator]]', stream)
)
page.Contents = Stream(pdf, content_stream)
+31
View File
@@ -337,6 +337,37 @@ def pikepdf_enable_mmap() -> None:
log.debug("pikepdf mmap not available")
def pikepdf_get_int(obj: pikepdf.Object, key: pikepdf.Name, default: int = 0) -> int:
"""Look up a key on a pikepdf dictionary/stream, returning a plain int.
``.get(key, default)``'s return type is the ambiguous ``Object | int``,
which does not support arithmetic or comparison against a plain int. In
pikepdf's default (implicit) conversion mode, a PDF Integer is already
unboxed to a native ``int`` by the time we see it here; under explicit
conversion mode it would instead be a ``pikepdf.Object``. ``int()``
handles both, since ``Object`` implements ``__int__``.
"""
value = obj.get(key)
return int(value) if value is not None else default
def pikepdf_get_bool(
obj: pikepdf.Object, key: pikepdf.Name, default: bool = False
) -> bool:
"""Look up a key on a pikepdf dictionary/stream, returning a plain bool.
Unlike ``int()``/``float()``, ``bool()`` is not supported on
``pikepdf.Object`` (it raises), so both conversion modes must be
handled explicitly. See :func:`pikepdf_get_int` for background.
"""
value = obj.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
return value.as_bool(default)
def running_in_docker() -> bool:
"""Returns True if we seem to be running in a Docker container."""
return Path('/.dockerenv').exists()
+9 -6
View File
@@ -12,7 +12,7 @@ import threading
from collections.abc import Callable, Iterator, MutableSet, Sequence
from os import fspath
from pathlib import Path
from typing import Any, NamedTuple, NewType
from typing import Any, NamedTuple, NewType, cast
from zlib import compress
import img2pdf
@@ -37,7 +37,7 @@ from ocrmypdf._exec import ghostscript, jbig2enc, pngquant
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import OutputFileAccessError
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
from ocrmypdf.helpers import IMG2PDF_KWARGS, pikepdf_get_int, safe_symlink
log = logging.getLogger(__name__)
@@ -527,8 +527,8 @@ def _find_deflatable_jpeg(
(
# Don't flate very large images because it will slow down PDF viewers
1 <= options.optimize <= 2
and image.get(Name.Width, 0) < FLATE_JPEG_THRESHOLD
and image.get(Name.Height, 0) < FLATE_JPEG_THRESHOLD
and pikepdf_get_int(image, Name.Width) < FLATE_JPEG_THRESHOLD
and pikepdf_get_int(image, Name.Height) < FLATE_JPEG_THRESHOLD
)
or options.optimize == 3
)
@@ -608,10 +608,13 @@ def _transcode_png(pdf: Pdf, filename: Path, xref: Xref) -> bool:
local_image = pdf.copy_foreign(foreign_image)
im_obj = pdf.get_object(xref, 0)
# pikepdf's Object attribute access can't statically know Filter/
# DecodeParms hold these specific subtypes, but a copied image's
# stream dictionary always does per the PDF spec.
im_obj.write(
local_image.read_raw_bytes(),
filter=local_image.Filter,
decode_parms=local_image.DecodeParms,
filter=cast('Name | Array | list[Name] | None', local_image.Filter),
decode_parms=cast('Dictionary | Array | None', local_image.DecodeParms),
)
# Don't copy keys from the new image...
+10 -6
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import logging
from collections.abc import Iterator
from decimal import Decimal
from typing import cast
from pikepdf import (
Dictionary,
@@ -20,7 +21,7 @@ from pikepdf import (
UnsupportedImageTypeError,
)
from ocrmypdf.helpers import Resolution
from ocrmypdf.helpers import Resolution, pikepdf_get_int
from ocrmypdf.pdfinfo._contentstream import (
ContentsInfo,
TextMarker,
@@ -54,6 +55,7 @@ class ImageInfo:
_comp: int | None
_name: str
_enc: Encoding | None
def __init__(
self,
@@ -90,8 +92,8 @@ class ImageInfo:
# itself. Some PDF writers use this to create a grayscale stencil
# mask. For our purposes, the effective size is the size of the
# larger component (image or smask).
self._width = max(smask.get(Name.Width, 0), self._width)
self._height = max(smask.get(Name.Height, 0), self._height)
self._width = max(pikepdf_get_int(smask, Name.Width), self._width)
self._height = max(pikepdf_get_int(smask, Name.Height), self._height)
if (mask := pim.obj.get(Name.Mask, None)) is not None and isinstance(
mask, Stream | Dictionary
):
@@ -99,8 +101,8 @@ class ImageInfo:
# /Mask can be a Stream or an Array. If it's a Stream,
# use its /Width and /Height if they are larger than the main
# image's.
self._width = max(mask.get(Name.Width, 0), self._width)
self._height = max(mask.get(Name.Height, 0), self._height)
self._width = max(pikepdf_get_int(mask, Name.Width), self._width)
self._height = max(pikepdf_get_int(mask, Name.Height), self._height)
# If /ImageMask is true, then this image is a stencil mask
# (Images that draw with this stencil mask will have a reference to
@@ -396,7 +398,9 @@ def _process_content_streams(
# A Form XObject may provide its own matrix to map form space into
# user space. Get this if one exists
form_shorthand = container.get(Name.Matrix, Matrix())
form_matrix = Matrix(form_shorthand)
# pikepdf's Matrix() stub omits the Object/Array overload, but the
# underlying C++ implementation accepts any 6-element numeric array.
form_matrix = Matrix(cast(Matrix, form_shorthand))
# Concatenate form matrix with CTM to ensure CTM is correct for
# drawing this instance of the XObject
+2 -2
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import atexit
import logging
from collections.abc import Container, Sequence
from collections.abc import Container
from contextlib import contextmanager
from functools import partial
from pathlib import Path
@@ -84,7 +84,7 @@ def _pdf_pageinfo_concurrent(
check_pages: Container[int],
detailed_analysis: bool = False,
miner_state: PdfMinerState | None = None,
) -> Sequence[PageInfo | None]:
) -> list[PageInfo | None]:
pages: list[PageInfo | None] = [None] * len(pdf.pages)
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
+21 -8
View File
@@ -16,12 +16,12 @@ from pathlib import Path
from typing import NamedTuple
from pdfminer.layout import LTPage, LTTextBox
from pikepdf import Name, Page, Pdf
from pikepdf import Name, Object, Page, Pdf
from ocrmypdf._concurrent import Executor, SerialExecutor
from ocrmypdf._pageboxes import coerce_box
from ocrmypdf.exceptions import EncryptedPdfError
from ocrmypdf.helpers import Resolution
from ocrmypdf.helpers import Resolution, pikepdf_get_bool, pikepdf_get_int
from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker
from ocrmypdf.pdfinfo._image import ImageInfo, _process_content_streams
from ocrmypdf.pdfinfo._types import FloatRect
@@ -160,6 +160,10 @@ class PageInfo:
check_this_page = pageno in check_pages
if check_this_page and detailed_analysis:
# miner_state is only None when detailed_analysis is False (see
# PdfInfo.__init__, which ties the two together), so it must be
# set here.
assert miner_state is not None
page_analysis = miner_state.get_page_analysis(pageno)
if page_analysis is not None:
self._textboxes = list(
@@ -175,7 +179,11 @@ class PageInfo:
self._has_text = None # i.e. "no information"
userunit = page.get(Name.UserUnit, Decimal(1.0))
if not isinstance(userunit, Decimal):
if isinstance(userunit, Object):
# Only reachable under pikepdf's explicit conversion mode; the
# default (implicit) mode already unboxes to int/float/Decimal.
userunit = Decimal(userunit.as_float())
elif not isinstance(userunit, Decimal):
userunit = Decimal(userunit)
self._userunit = userunit
self._width_inches = width_pt * userunit / Decimal(72.0)
@@ -189,7 +197,7 @@ class PageInfo:
self._has_text = False
self._images = []
for info in _process_content_streams(
pdf=pdf, container=page, shorthand=userunit_shorthand
pdf=pdf, container=page.obj, shorthand=userunit_shorthand
):
if isinstance(info, VectorMarker):
self._has_vector = True
@@ -446,16 +454,21 @@ class PdfInfo:
detailed_analysis=detailed_analysis,
miner_state=miner_state,
)
self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
self._needs_rendering = pikepdf_get_bool(pdf.Root, Name.NeedsRendering)
if Name.AcroForm in pdf.Root:
if (
len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0
or Name.XFA in pdf.Root.AcroForm
):
self._has_acroform = True
self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1)
self._is_tagged = bool(
pdf.Root.get(Name.MarkInfo, {}).get(Name.Marked, False)
self._has_signature = bool(
pikepdf_get_int(pdf.Root.AcroForm, Name.SigFlags) & 1
)
mark_info = pdf.Root.get(Name.MarkInfo)
self._is_tagged = (
pikepdf_get_bool(mark_info, Name.Marked)
if mark_info is not None
else False
)
self._has_structure_tree = Name.StructTreeRoot in pdf.Root