From 12ec97f73246348f0fee5c80bd2ba2c98425a926 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 7 Jul 2026 00:57:45 -0700 Subject: [PATCH] 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. --- src/ocrmypdf/fpdf_renderer/renderer.py | 22 +++++++++-- tests/test_pdf_renderer.py | 54 ++++++++++++++------------ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/src/ocrmypdf/fpdf_renderer/renderer.py b/src/ocrmypdf/fpdf_renderer/renderer.py index 54279a50..25827b99 100644 --- a/src/ocrmypdf/fpdf_renderer/renderer.py +++ b/src/ocrmypdf/fpdf_renderer/renderer.py @@ -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. diff --git a/tests/test_pdf_renderer.py b/tests/test_pdf_renderer.py index 218b7df7..b95466b8 100644 --- a/tests/test_pdf_renderer.py +++ b/tests/test_pdf_renderer.py @@ -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: