Fix remaining mypy errors in fpdf_renderer and its tests

Resolves the last 16 mypy errors in the project (src/ocrmypdf and
tests are now fully clean).

fpdf_renderer/renderer.py (9 errors):
- add_page(format=...): fpdf2's own stub types this param as str, but
  its docstring and get_page_format() helper confirm a (width, height)
  tuple is accepted too - the stub annotation on add_page() itself is
  the outlier. Used cast() to match the documented/actual behavior.
- pdf.current_font is typed CoreFont | TTFFont | None, but this
  renderer only ever registers fonts via add_font() with a TTF file
  (see _register_font/set_font call sites) - it never falls back to
  fpdf2's built-in CoreFont. Added assertions (isinstance(font,
  TTFFont) where shape_text()/escape_text() are needed, which
  CoreFont lacks; plain not-None elsewhere) documenting that
  invariant instead of narrowing defensively for a case that can't
  happen here.

tests/test_pdf_renderer.py (7 errors): the ToUnicode/glyph-extraction
test helpers used `.get(key, {})` (a plain dict literal default) then
called `.values()`/`.items()` on the result. pikepdf.Object doesn't
declare `values()` in its stub (only `keys()`), so this silently
degraded to Object's catch-all `__getattr__` returning another Object,
which then failed as "not callable". Switched to `.get(key,
Dictionary()).as_dict()`, which returns pikepdf's properly-typed
_ObjectMapping helper.
This commit is contained in:
James R. Barlow
2026-07-07 00:57:45 -07:00
parent 3f1aceade2
commit 12ec97f732
2 changed files with 49 additions and 27 deletions
+19 -3
View File
@@ -14,9 +14,11 @@ import unicodedata
from dataclasses import dataclass
from math import atan, cos, degrees, radians, sin, sqrt
from pathlib import Path
from typing import cast
from fpdf import FPDF
from fpdf.enums import PDFResourceType, TextMode
from fpdf.fonts import TTFFont
from pikepdf import Matrix, Rectangle
from ocrmypdf.font import FontManager, MultiFontManager
@@ -241,10 +243,16 @@ class Fpdf2PdfRenderer:
pdf: FPDF instance to render into
"""
# Add page with correct dimensions
# fpdf2's add_page() stub says format: str, but its docstring and
# get_page_format() helper confirm a (width, height) tuple is
# supported too - the annotation on add_page() itself is just wrong.
pdf.add_page(
format=(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
format=cast(
'str',
(
self.coord_transform.page_width_pt,
self.coord_transform.page_height_pt,
),
)
)
@@ -695,6 +703,10 @@ class Fpdf2PdfRenderer:
# Set font if changed
if word.font_family != prev_font_family:
pdf.set_font(word.font_family, size=font_size)
# We only ever register fonts via add_font() with a TTF file
# (see _register_font), so set_font() always resolves to a
# TTFFont, never a built-in CoreFont or leaves it unset.
assert pdf.current_font is not None
# Register font resource on this page
pdf._resource_catalog.add(
PDFResourceType.FONT, pdf.current_font.i, pdf.page
@@ -773,6 +785,10 @@ class Fpdf2PdfRenderer:
joining forms and ligature shaping is harmless.
"""
font = pdf.current_font
# We only ever register fonts via add_font() with a TTF file (see
# _register_font), so current_font is always a TTFFont - never the
# built-in CoreFont (which lacks shape_text()/escape_text()) or None.
assert isinstance(font, TTFFont)
if is_rtl:
# Reverse the text so that after bidi reversal by the text
# extractor, the characters end up in correct logical order.
+30 -24
View File
@@ -655,16 +655,19 @@ def _tounicode_map(pdf_path: Path) -> dict[int, str]:
"""
pdf = pikepdf.open(pdf_path)
page = pdf.pages[0]
resources = page.get('/Resources', {})
resources = page.get('/Resources', pikepdf.Dictionary()).as_dict()
# Collect fonts from the page and from any Form XObjects (OCR overlay)
fonts: dict[str, pikepdf.Object] = {}
if '/Font' in resources:
for name, obj in resources['/Font'].items():
for name, obj in resources['/Font'].as_dict().items():
fonts[str(name)] = obj
for xobj in resources.get('/XObject', {}).values():
for xobj in resources.get('/XObject', pikepdf.Dictionary()).as_dict().values():
if xobj.get('/Subtype') == '/Form':
for name, obj in xobj.get('/Resources', {}).get('/Font', {}).items():
xobj_resources = xobj.get('/Resources', pikepdf.Dictionary()).as_dict()
for name, obj in (
xobj_resources.get('/Font', pikepdf.Dictionary()).as_dict().items()
):
fonts[str(name)] = obj
result: dict[int, str] = {}
@@ -701,30 +704,33 @@ def _decode_tounicode_stream(
"""
pdf = pikepdf.open(pdf_path)
page = pdf.pages[0]
resources = page.get('/Resources', {})
resources = page.get('/Resources', pikepdf.Dictionary()).as_dict()
# Collect fonts from page and from Form XObjects
cmap: dict[int, str] = {}
for font_dict in [resources.get('/Font', {})]:
for fobj in font_dict.values():
tounicode = fobj.get('/ToUnicode')
if tounicode is None:
continue
raw = bytes(tounicode.read_bytes()).decode('latin-1', errors='replace')
for m in re.finditer(r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', raw):
src = int(m.group(1), 16)
dst_hex = m.group(2)
chars = ''.join(
chr(int(dst_hex[i : i + 4], 16))
for i in range(0, len(dst_hex), 4)
if int(dst_hex[i : i + 4], 16) > 0
)
if src > 0 and chars:
cmap[src] = chars
for xobj in resources.get('/XObject', {}).values():
font_dict = resources.get('/Font', pikepdf.Dictionary()).as_dict()
for fobj in font_dict.values():
tounicode = fobj.get('/ToUnicode')
if tounicode is None:
continue
raw = bytes(tounicode.read_bytes()).decode('latin-1', errors='replace')
for m in re.finditer(r'<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>', raw):
src = int(m.group(1), 16)
dst_hex = m.group(2)
chars = ''.join(
chr(int(dst_hex[i : i + 4], 16))
for i in range(0, len(dst_hex), 4)
if int(dst_hex[i : i + 4], 16) > 0
)
if src > 0 and chars:
cmap[src] = chars
for xobj in resources.get('/XObject', pikepdf.Dictionary()).as_dict().values():
if xobj.get('/Subtype') != '/Form':
continue
for fobj in xobj.get('/Resources', {}).get('/Font', {}).values():
xobj_resources = xobj.get('/Resources', pikepdf.Dictionary()).as_dict()
for fobj in (
xobj_resources.get('/Font', pikepdf.Dictionary()).as_dict().values()
):
tounicode = fobj.get('/ToUnicode')
if tounicode is None:
continue
@@ -746,7 +752,7 @@ def _decode_tounicode_stream(
contents = page.get('/Contents')
if contents:
streams.append(bytes(contents.read_bytes()))
for xobj in resources.get('/XObject', {}).values():
for xobj in resources.get('/XObject', pikepdf.Dictionary()).as_dict().values():
if xobj.get('/Subtype') == '/Form':
streams.append(bytes(xobj.read_bytes()))
for data in streams: