Reduces mypy errors in src/ocrmypdf and tests from 89 to 51. - Replace the `deprecation` package with stdlib `warnings.deprecated` (falling back to typing_extensions on <3.13); drop the dependency. - Add pypdfium2/uharfbuzz/pi_heif to mypy's ignore_missing_imports overrides (no upstream stubs); drop pluggy, which now ships py.typed. - Add a tests.* mypy override so test functions aren't required to annotate -> None. Real bugs found and fixed along the way, not just annotations: - OcrmypdfPluginManager had a `pluggy` property shadowing the `pluggy` module import within its own class body, breaking every `pluggy.PluginManager` annotation below it; renamed to `pluggy_manager`. - `_option_registry` was bolted onto OcrmypdfPluginManager from outside and read via `getattr(..., None)` instead of being a declared attribute; declared it properly. - ValidationCoordinator.__init__ was typed to accept a raw pluggy.PluginManager, but every caller passes the OcrmypdfPluginManager wrapper. - check_options_sidecar() did `options.output_file + '.txt'`, assuming output_file is always a str; would raise a raw TypeError if ever hit with a stream/bytes output. Added an explicit guard. - is_file_writable() called Path(test_file), which raises TypeError on a bytes path; fixed via os.fsdecode(). - copy_final() had a dead, unused `original_file` parameter; removed it. - run_hocr_pipeline() constructed PdfContext with the raw, untriaged input_file instead of the locally-copied origin_pdf, inconsistent with the other two pipelines. - _options.py had jbig2_threshold declared twice in the same model.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
"""Built-in plugin to implement PDF page rasterization using pypdfium2."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Literal
|
|
|
|
if TYPE_CHECKING:
|
|
import pypdfium2 as pdfium
|
|
else:
|
|
try:
|
|
import pypdfium2 as pdfium
|
|
except ImportError:
|
|
pdfium = None
|
|
from PIL import Image
|
|
|
|
from ocrmypdf import hookimpl
|
|
from ocrmypdf.exceptions import MissingDependencyError
|
|
from ocrmypdf.helpers import Resolution
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# pypdfium2/PDFium is not thread-safe. All calls to the library must be serialized.
|
|
# See: https://pypdfium2.readthedocs.io/en/stable/python_api.html#incompatibility-with-threading
|
|
# When using process-based parallelism (use_threads=False), each process has its own
|
|
# pdfium instance, so locking is not needed across processes.
|
|
_pdfium_lock = threading.Lock()
|
|
|
|
|
|
@hookimpl
|
|
def check_options(options):
|
|
"""Check that pypdfium2 is available if explicitly requested."""
|
|
if options.rasterizer == 'pypdfium' and pdfium is None:
|
|
raise MissingDependencyError(
|
|
"The --rasterizer pypdfium option requires the pypdfium2 package. "
|
|
"Install it with: pip install pypdfium2"
|
|
)
|
|
|
|
|
|
def _open_pdf_document(input_file: Path):
|
|
"""Open a PDF document using pypdfium2."""
|
|
assert pdfium is not None, "pypdfium2 must be available to call this function"
|
|
return pdfium.PdfDocument(input_file)
|
|
|
|
|
|
def _expand_cropbox_to_mediabox(page) -> None:
|
|
"""Set the page's CropBox to its MediaBox so PDFium renders the full page.
|
|
|
|
PDFium renders to the CropBox by default. Negative ``crop`` values to
|
|
``render()`` are not supported and only pad the output canvas without
|
|
expanding the rendered area — content outside the CropBox is clipped.
|
|
The supported approach is to widen the CropBox in memory before rendering.
|
|
The document is never saved back to disk, so this mutation is local.
|
|
See https://github.com/ocrmypdf/OCRmyPDF/issues/1685.
|
|
"""
|
|
mediabox = page.get_mediabox() # (left, bottom, right, top)
|
|
page.set_cropbox(*mediabox)
|
|
|
|
|
|
def _render_page_to_bitmap(
|
|
page: pdfium.PdfPage,
|
|
raster_device: str,
|
|
raster_dpi: Resolution,
|
|
rotation: int | None,
|
|
use_cropbox: bool,
|
|
) -> tuple[pdfium.PdfBitmap, int, int]:
|
|
"""Render a PDF page to a bitmap."""
|
|
# Round DPI to match Ghostscript's precision
|
|
raster_dpi = raster_dpi.round(6)
|
|
|
|
# Get page dimensions BEFORE applying rotation
|
|
page_width_pts, page_height_pts = page.get_size()
|
|
|
|
# Calculate expected output dimensions using separate x/y DPI
|
|
expected_width = int(round(page_width_pts * raster_dpi.x / 72.0))
|
|
expected_height = int(round(page_height_pts * raster_dpi.y / 72.0))
|
|
|
|
# Calculate the scale factor based on DPI
|
|
# pypdfium2 uses points (72 DPI) as base unit
|
|
scale = raster_dpi.to_scalar() / 72.0
|
|
|
|
# Apply rotation if specified
|
|
if rotation:
|
|
# pypdfium2 rotation is in degrees, same as our input
|
|
# we track rotation in CCW, and pypdfium2 expects CW, so negate
|
|
page.set_rotation(-rotation % 360)
|
|
# When rotation is 90 or 270, dimensions are swapped in output
|
|
if rotation % 180 == 90:
|
|
expected_width, expected_height = expected_height, expected_width
|
|
|
|
# Render the page to a bitmap
|
|
# The scale parameter controls the resolution
|
|
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
|
grayscale = raster_device.lower() in (
|
|
'pngmono',
|
|
'pngmonod',
|
|
'pnggray',
|
|
'jpeggray',
|
|
)
|
|
|
|
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
|
if not use_cropbox:
|
|
_expand_cropbox_to_mediabox(page)
|
|
|
|
bitmap = page.render(
|
|
scale=scale,
|
|
rotation=0, # We already set rotation on the page
|
|
may_draw_forms=True,
|
|
draw_annots=True,
|
|
grayscale=grayscale,
|
|
# Note: pypdfium2 doesn't have a direct equivalent to filter_vector
|
|
# This would require more complex implementation if needed
|
|
)
|
|
return bitmap, expected_width, expected_height
|
|
|
|
|
|
def _process_image_for_output(
|
|
pil_image: Image.Image,
|
|
raster_device: str,
|
|
raster_dpi: Resolution,
|
|
page_dpi: Resolution | None,
|
|
stop_on_soft_error: bool,
|
|
expected_width: int | None = None,
|
|
expected_height: int | None = None,
|
|
) -> tuple[Image.Image, Literal['PNG', 'TIFF', 'JPEG']]:
|
|
"""Process PIL image for output format and set DPI metadata."""
|
|
# Correct dimensions if slightly off (within 2 pixels tolerance)
|
|
if expected_width and expected_height:
|
|
actual_width, actual_height = pil_image.width, pil_image.height
|
|
width_diff = abs(actual_width - expected_width)
|
|
height_diff = abs(actual_height - expected_height)
|
|
|
|
# Only resize if off by small amount (1-2 pixels)
|
|
if (width_diff <= 2 or height_diff <= 2) and (
|
|
width_diff > 0 or height_diff > 0
|
|
):
|
|
log.debug(
|
|
f"Adjusting rendered dimensions from "
|
|
f"{actual_width}x{actual_height} to expected "
|
|
f"{expected_width}x{expected_height}"
|
|
)
|
|
pil_image = pil_image.resize(
|
|
(expected_width, expected_height), Image.Resampling.LANCZOS
|
|
)
|
|
|
|
# Set the DPI metadata if page_dpi is specified
|
|
if page_dpi:
|
|
# PIL expects DPI as a tuple
|
|
dpi_tuple = (float(page_dpi.x), float(page_dpi.y))
|
|
pil_image.info['dpi'] = dpi_tuple
|
|
else:
|
|
# Use the raster DPI
|
|
dpi_tuple = (float(raster_dpi.x), float(raster_dpi.y))
|
|
pil_image.info['dpi'] = dpi_tuple
|
|
|
|
# Convert image mode to match raster_device
|
|
# This ensures pypdfium output matches Ghostscript's native device output
|
|
raster_device_lower = raster_device.lower()
|
|
|
|
if raster_device_lower in ('pngmono', 'pngmonod'):
|
|
# Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod)
|
|
if pil_image.mode != '1':
|
|
if pil_image.mode not in ('L', '1'):
|
|
pil_image = pil_image.convert('L')
|
|
pil_image = pil_image.convert('1')
|
|
elif raster_device_lower in ('pnggray', 'jpeggray'):
|
|
# Convert to 8-bit grayscale
|
|
if pil_image.mode not in ('L', '1'):
|
|
pil_image = pil_image.convert('L')
|
|
elif raster_device_lower == 'png256':
|
|
# Convert to 8-bit indexed color (256 colors)
|
|
if pil_image.mode != 'P':
|
|
if pil_image.mode not in ('RGB', 'RGBA'):
|
|
pil_image = pil_image.convert('RGB')
|
|
pil_image = pil_image.quantize(colors=256)
|
|
elif raster_device_lower in ('png16m', 'jpeg'):
|
|
# Convert to RGB
|
|
if pil_image.mode == 'RGBA':
|
|
background = Image.new('RGB', pil_image.size, (255, 255, 255))
|
|
background.paste(pil_image, mask=pil_image.split()[-1])
|
|
pil_image = background
|
|
elif pil_image.mode not in ('RGB',):
|
|
pil_image = pil_image.convert('RGB')
|
|
# pngalpha: keep RGBA as-is
|
|
|
|
# Determine output format based on raster_device
|
|
png_devices = (
|
|
'png',
|
|
'pngmono',
|
|
'pngmonod',
|
|
'pnggray',
|
|
'png256',
|
|
'png16m',
|
|
'pngalpha',
|
|
)
|
|
format_name: Literal['PNG', 'TIFF', 'JPEG']
|
|
if raster_device_lower in png_devices:
|
|
format_name = 'PNG'
|
|
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
|
format_name = 'JPEG'
|
|
elif raster_device_lower in ('tiff', 'tif'):
|
|
format_name = 'TIFF'
|
|
else:
|
|
# Default to PNG for unknown formats
|
|
format_name = 'PNG'
|
|
if stop_on_soft_error:
|
|
raise ValueError(f"Unsupported raster device: {raster_device}")
|
|
else:
|
|
log.warning(f"Unsupported raster device {raster_device}, using PNG")
|
|
|
|
return pil_image, format_name
|
|
|
|
|
|
def _save_image(pil_image: Image.Image, output_file: Path, format_name: str) -> None:
|
|
"""Save PIL image to file with appropriate DPI metadata."""
|
|
save_kwargs = {}
|
|
if (
|
|
format_name in ('PNG', 'TIFF')
|
|
and 'dpi' in pil_image.info
|
|
or format_name == 'JPEG'
|
|
and 'dpi' in pil_image.info
|
|
):
|
|
save_kwargs['dpi'] = pil_image.info['dpi']
|
|
|
|
pil_image.save(output_file, format=format_name, **save_kwargs)
|
|
|
|
|
|
@hookimpl
|
|
def rasterize_pdf_page(
|
|
input_file: Path,
|
|
output_file: Path,
|
|
raster_device: str,
|
|
raster_dpi: Resolution,
|
|
pageno: int,
|
|
page_dpi: Resolution | None,
|
|
rotation: int | None,
|
|
filter_vector: bool,
|
|
stop_on_soft_error: bool,
|
|
options,
|
|
use_cropbox: bool,
|
|
) -> Path | None:
|
|
"""Rasterize a single page of a PDF file using pypdfium2.
|
|
|
|
Returns None if pypdfium2 is not available or if the user has selected
|
|
a different rasterizer, allowing Ghostscript to be used.
|
|
"""
|
|
# Check if user explicitly requested a different rasterizer
|
|
if options is not None and options.rasterizer == 'ghostscript':
|
|
return None # Let Ghostscript handle it
|
|
|
|
if pdfium is None:
|
|
return None # Fall back to Ghostscript
|
|
|
|
log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno)
|
|
|
|
# Acquire lock to ensure thread-safe access to pypdfium2
|
|
with (
|
|
_pdfium_lock,
|
|
closing(_open_pdf_document(input_file)) as pdf,
|
|
closing(pdf[pageno - 1]) as page,
|
|
):
|
|
# Render the page to a bitmap
|
|
bitmap, expected_width, expected_height = _render_page_to_bitmap(
|
|
page, raster_device, raster_dpi, rotation, use_cropbox
|
|
)
|
|
with closing(bitmap):
|
|
# Convert to PIL Image
|
|
pil_image = bitmap.to_pil()
|
|
|
|
# Process and save image outside the lock (PIL operations are thread-safe)
|
|
pil_image, format_name = _process_image_for_output(
|
|
pil_image,
|
|
raster_device,
|
|
raster_dpi,
|
|
page_dpi,
|
|
stop_on_soft_error,
|
|
expected_width,
|
|
expected_height,
|
|
)
|
|
|
|
_save_image(pil_image, output_file, format_name)
|
|
|
|
return output_file
|