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.
Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
extra word breaks or substituted characters. Enable text and graphics
anti-aliasing (-dTextAlphaBits=4 -dGraphicsAlphaBits=4) for the contone
raster devices, which empirically improves OCR accuracy on the
Ghostscript path, especially for small fonts at moderate DPI. The 1-bit
mono devices are excluded, since older Ghostscript rejects alpha bits on
them and pngmonod performs its own anti-aliased downscaling.
Also log which rasterizer rendered each page at debug verbosity and
clarify the --rasterizer help text, so quality reports are easier to
diagnose. The default rasterizer (auto) already prefers pypdfium2, which
anti-aliases; this primarily benefits --rasterizer ghostscript and
installs without pypdfium2.
Closes#1439.
PDFium does not support negative crop values to expand the render
area beyond the CropBox: such values only pad the output canvas with
white, leaving content outside the CropBox clipped. Set the in-memory
CropBox to the MediaBox before rendering instead. Reported in #1685.
pypdfium was not converting images to the correct colorspace/mode based
on the raster_device parameter. For example, pngmono should produce a
1-bit image, but pypdfium was outputting full-color or grayscale images.
This caused images to be incorrectly promoted to PNG instead of being
preserved as CCITT/JBIG2 when using --force-ocr with pypdfium, because
the optimizer relies on the image being in the correct mode.
Changes:
- Render in grayscale for pngmono device (better input for 1-bit conversion)
- Add mode conversion to match Ghostscript's native device output:
- pngmono: convert to mode '1' (1-bit)
- pnggray/jpeggray: convert to mode 'L' (8-bit grayscale)
- png256: convert to mode 'P' (8-bit indexed)
- png16m/jpeg: convert to mode 'RGB'
The pypdfium rasterizer was producing output images that differed by 1
pixel compared to Ghostscript due to floating-point precision issues in
dimension calculations.
Root cause:
- pypdfium used harmonic mean of x/y DPI to calculate a single scale
factor, losing the distinction between x and y DPI
- No DPI rounding like Ghostscript's 6-decimal precision
- Compound rounding errors when converting points to pixels
Solution:
1. Round DPI to 6 decimals to match Ghostscript's precision
2. Calculate expected output dimensions using separate x/y DPI values
3. Handle dimension swapping for 90°/270° rotations
4. Resize output image if off by 1-2 pixels (graceful correction)
This ensures pixel-perfect matching with Ghostscript while being
minimally invasive and only resizing when necessary.
Changes:
- Modified _render_page_to_bitmap() to calculate expected dimensions
- Modified _process_image_for_output() to correct small discrepancies
- Updated rasterize_pdf_page() to pass dimensions through pipeline
- Parametrized rotation tests to run with both rasterizers
All 45 rotation tests now pass with both pypdfium and ghostscript.
Fixes test_rotated_skew_timeout with pypdfium rasterizer.
pypdfium2 expects clockwise rotation values, but OCRmyPDF tracks
rotation in counter-clockwise. Negate the rotation value to fix.
Also refactor nested try/finally blocks to use contextlib.closing()
for cleaner resource management.
Added use_cropbox parameter to rasterize_pdf_page hook to allow
choosing between MediaBox and CropBox rendering:
- Default is use_cropbox=False (MediaBox) for consistency with
Ghostscript's existing behavior
- Ghostscript: passes -dUseCropBox when use_cropbox=True
- pypdfium: calculates crop values to expand from CropBox to MediaBox
when use_cropbox=False
This aligns both rasterizers to produce the same output dimensions
by default, making the rasterizer choice transparent for page
geometry.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
pypdfium2/PDFium is not thread-safe - concurrent calls from different
threads can crash or corrupt the process. Added a module-level lock to
serialize all pdfium operations.
PIL image processing and file I/O are done outside the lock since they
are thread-safe, minimizing lock contention.
For maximum parallelism, users can use process-based parallelism
(use_threads=False) where each process has its own pdfium instance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add test_rasterizer.py with tests covering:
- Basic rasterizer option validation ('auto', 'ghostscript', 'pypdfium')
- Rasterizer + --rotate-pages interaction
- PDFs with nonstandard MediaBox/TrimBox/CropBox
- Direct hook tests verifying plugins respect the option
Also fix pluggy parameter passing: make 'options' a required parameter
(no default) in the hookspec so pluggy forwards it to implementations.
Update test plugins and test_rotation.py to pass the new parameter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add user control over which rasterizer is used for PDF page rendering:
- 'auto' (default): prefers pypdfium when available, falls back to Ghostscript
- 'pypdfium': force pypdfium2 (errors if not installed)
- 'ghostscript': force traditional Ghostscript rasterizer
Changes:
- Add rasterizer field with validation to OCROptions model
- Add --rasterizer CLI argument in the Advanced options group
- Update rasterize_pdf_page hookspec to pass options to plugins
- Update pypdfium plugin with check_options hook for availability check
- Update both plugins to respect the rasterizer option
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove check_options hook from pypdfium that raised error when
pypdfium2 wasn't installed
- Return None from pypdfium's rasterize_pdf_page when pypdfium2 is
unavailable, allowing the hook to fall through
- Restore Ghostscript's rasterize_pdf_page hook as fallback
This allows OCRmyPDF to work without pypdfium2 installed, using
Ghostscript for rasterization as before.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>