feat: add use_cropbox parameter to align rasterizer APIs

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>
This commit is contained in:
James R. Barlow
2025-12-21 12:29:17 -08:00
co-authored by Claude Opus 4.5
parent ae783b4ae6
commit 3e46b039ed
9 changed files with 93 additions and 39 deletions
+8 -1
View File
@@ -105,8 +105,14 @@ def rasterize_pdf(
rotation: int | None = None,
filter_vector: bool = False,
stop_on_error: bool = False,
use_cropbox: bool = False,
):
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units."""
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
Args:
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
Default is False (use MediaBox).
"""
raster_dpi = raster_dpi.round(6)
if not page_dpi:
page_dpi = raster_dpi
@@ -123,6 +129,7 @@ def rasterize_pdf(
f'-dLastPage={pageno}',
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
]
+ (['-dUseCropBox'] if use_cropbox else [])
+ (['-dFILTERVECTOR'] if filter_vector else [])
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ [
+2
View File
@@ -406,6 +406,7 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
filter_vector=False,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -566,6 +567,7 @@ def rasterize(
filter_vector=remove_vectors,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -129,6 +129,7 @@ def rasterize_pdf_page(
filter_vector,
stop_on_soft_error,
options,
use_cropbox,
):
"""Rasterize a single page of a PDF file using Ghostscript."""
# Check if user explicitly requested a different rasterizer
@@ -145,6 +146,7 @@ def rasterize_pdf_page(
rotation=rotation,
filter_vector=filter_vector,
stop_on_error=stop_on_soft_error,
use_cropbox=use_cropbox,
)
return output_file
+38 -2
View File
@@ -42,8 +42,35 @@ def _open_pdf_document(input_file: Path):
return pdfium.PdfDocument(input_file)
def _calculate_mediabox_crop(page) -> tuple[float, float, float, float]:
"""Calculate crop values to expand rendering from CropBox to MediaBox.
By default pypdfium2 renders to the CropBox. To render the full MediaBox,
we need negative crop values to expand the rendering area.
Returns:
Tuple of (left, bottom, right, top) crop values. Negative values
expand the rendering area beyond the CropBox to the MediaBox.
"""
mediabox = page.get_mediabox() # (left, bottom, right, top)
cropbox = page.get_cropbox() # (left, bottom, right, top), defaults to mediabox
# Calculate how much to expand from cropbox to mediabox
# Negative values = expand, positive = shrink
return (
mediabox[0] - cropbox[0], # Expand left
mediabox[1] - cropbox[1], # Expand bottom
cropbox[2] - mediabox[2], # Expand right
cropbox[3] - mediabox[3], # Expand top
)
def _render_page_to_bitmap(
page, raster_device: str, raster_dpi: Resolution, rotation: int | None
page,
raster_device: str,
raster_dpi: Resolution,
rotation: int | None,
use_cropbox: bool,
):
"""Render a PDF page to a bitmap."""
# Calculate the scale factor based on DPI
@@ -59,9 +86,17 @@ def _render_page_to_bitmap(
# The scale parameter controls the resolution
grayscale = raster_device.lower() in ('pnggray', 'jpeggray')
# Calculate crop to render the appropriate box
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
if use_cropbox:
crop = (0, 0, 0, 0) # No crop adjustment, use default CropBox
else:
crop = _calculate_mediabox_crop(page) # Expand to MediaBox
bitmap = page.render(
scale=scale,
rotation=0, # We already set rotation on the page
crop=crop,
may_draw_forms=True,
draw_annots=True,
grayscale=grayscale,
@@ -138,6 +173,7 @@ def rasterize_pdf_page(
filter_vector: bool,
stop_on_soft_error: bool,
options,
use_cropbox: bool,
) -> Path | None:
"""Rasterize a single page of a PDF file using pypdfium2.
@@ -163,7 +199,7 @@ def rasterize_pdf_page(
try:
# Render the page to a bitmap
bitmap = _render_page_to_bitmap(
page, raster_device, raster_dpi, rotation
page, raster_device, raster_dpi, rotation, use_cropbox
)
try:
+4
View File
@@ -214,6 +214,7 @@ def rasterize_pdf_page(
filter_vector: bool,
stop_on_soft_error: bool,
options: OCROptions | None,
use_cropbox: bool,
) -> Path: # type: ignore[return-value]
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
@@ -240,6 +241,9 @@ def rasterize_pdf_page(
options: OCRmyPDF options. Plugins may use this to check settings like
``options.rasterizer`` to determine whether they should handle the
request or defer to another plugin. Introduced in version 17.0.
use_cropbox: If True, rasterize the page's CropBox instead of the
MediaBox. Default is False (use MediaBox) for consistency with
Ghostscript's default behavior.
Returns:
Path: output_file if successful
+2
View File
@@ -29,6 +29,7 @@ def rasterize_pdf_page(
filter_vector,
stop_on_soft_error,
options,
use_cropbox,
) -> Path:
with patch('ocrmypdf._exec.ghostscript.run') as mock:
mock.side_effect = raise_gs_fail
@@ -43,6 +44,7 @@ def rasterize_pdf_page(
filter_vector=filter_vector,
stop_on_soft_error=stop_on_soft_error,
options=options,
use_cropbox=use_cropbox,
)
mock.assert_called()
return output_file
+2
View File
@@ -30,6 +30,7 @@ def rasterize_pdf_page(
filter_vector,
stop_on_soft_error,
options,
use_cropbox,
) -> Path:
with patch('ocrmypdf._exec.ghostscript.run') as mock:
mock.side_effect = fail_if_stoponerror
@@ -44,6 +45,7 @@ def rasterize_pdf_page(
filter_vector=filter_vector,
stop_on_soft_error=stop_on_soft_error,
options=options,
use_cropbox=use_cropbox,
)
mock.assert_called()
return output_file
+33 -36
View File
@@ -145,6 +145,7 @@ class TestRasterizerHookDirect:
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
# When pypdfium is requested:
# - If pypdfium IS available, pypdfium handles it and returns the path
@@ -179,6 +180,7 @@ class TestRasterizerHookDirect:
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
# Ghostscript should handle it
assert result == img
@@ -206,6 +208,7 @@ class TestRasterizerHookDirect:
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
assert result == img
assert img.exists()
@@ -378,6 +381,7 @@ class TestRasterizerWithNonStandardBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=options_gs,
use_cropbox=False,
)
with Image.open(img_gs) as im_gs:
@@ -402,17 +406,16 @@ class TestRasterizerWithNonStandardBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=options_pdfium,
use_cropbox=False,
)
with Image.open(img_pdfium) as im_pdfium:
pdfium_size = im_pdfium.size
# Note: Ghostscript and pypdfium use different page boxes:
# - Ghostscript uses MediaBox (400x500)
# - pypdfium uses CropBox (300x400)
# This is expected behavior - verify each produces valid output
# Both rasterizers should now produce MediaBox dimensions (400x500)
# when use_cropbox=False (the default)
assert gs_size == (400, 500), f"Ghostscript size: {gs_size}"
assert pdfium_size == (300, 400), f"pypdfium size: {pdfium_size}"
assert pdfium_size == (400, 500), f"pypdfium size: {pdfium_size}"
class TestRasterizerWithRotationAndBoxes:
@@ -423,22 +426,13 @@ class TestRasterizerWithRotationAndBoxes:
# - CropBox: [50, 50, 350, 450] → 300x400 points
# - TrimBox: [75, 75, 325, 425] → 250x350 points
#
# The rasterizers use different boxes:
# - Ghostscript uses MediaBox → 400x500 pixels at 72 DPI
# - pypdfium uses CropBox → 300x400 pixels at 72 DPI
GS_WIDTH = 400 # MediaBox width
GS_HEIGHT = 500 # MediaBox height
PDFIUM_WIDTH = 300 # CropBox width
PDFIUM_HEIGHT = 400 # CropBox height
# With use_cropbox=False (default), both rasterizers use MediaBox
MEDIABOX_WIDTH = 400
MEDIABOX_HEIGHT = 500
def _get_expected_size(
self, rotation: int, rasterizer: str = 'ghostscript'
) -> tuple[int, int]:
def _get_expected_size(self, rotation: int) -> tuple[int, int]:
"""Get expected image dimensions after rotation."""
if rasterizer == 'ghostscript':
width, height = self.GS_WIDTH, self.GS_HEIGHT
else:
width, height = self.PDFIUM_WIDTH, self.PDFIUM_HEIGHT
width, height = self.MEDIABOX_WIDTH, self.MEDIABOX_HEIGHT
if rotation in (0, 180):
return (width, height)
@@ -470,11 +464,12 @@ class TestRasterizerWithRotationAndBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
assert img_path.exists(), f"Failed to rasterize with rotation {rotation}"
with Image.open(img_path) as img:
expected = self._get_expected_size(rotation, 'ghostscript')
expected = self._get_expected_size(rotation)
# Allow small tolerance for rounding
assert abs(img.size[0] - expected[0]) <= 2, (
f"Width mismatch at {rotation}°: got {img.size[0]}, "
@@ -511,11 +506,12 @@ class TestRasterizerWithRotationAndBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
assert img_path.exists(), f"Failed to rasterize with rotation {rotation}"
with Image.open(img_path) as img:
expected = self._get_expected_size(rotation, 'pypdfium')
expected = self._get_expected_size(rotation)
# Allow small tolerance for rounding
assert abs(img.size[0] - expected[0]) <= 2, (
f"Width mismatch at {rotation}°: got {img.size[0]}, "
@@ -527,13 +523,13 @@ class TestRasterizerWithRotationAndBoxes:
)
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
def test_rasterizers_dimensions_differ_as_expected(
def test_rasterizers_produce_same_dimensions(
self, pdf_with_nonstandard_boxes, tmp_path
):
"""Verify ghostscript and pypdfium produce expected different dimensions.
"""Verify ghostscript and pypdfium produce the same MediaBox dimensions.
Ghostscript uses MediaBox while pypdfium uses CropBox, so their output
dimensions differ for PDFs with different MediaBox/CropBox sizes.
With use_cropbox=False (the default), both rasterizers should render
to the MediaBox and produce identical dimensions.
"""
pm = get_plugin_manager([])
@@ -556,6 +552,7 @@ class TestRasterizerWithRotationAndBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=gs_options,
use_cropbox=False,
)
# Rasterize with pypdfium
@@ -576,28 +573,28 @@ class TestRasterizerWithRotationAndBoxes:
filter_vector=False,
stop_on_soft_error=True,
options=pdfium_options,
use_cropbox=False,
)
# Verify each produces its expected dimensions
# Verify both produce the same MediaBox dimensions
with Image.open(gs_img_path) as gs_img, Image.open(
pdfium_img_path
) as pdfium_img:
gs_expected = self._get_expected_size(rotation, 'ghostscript')
pdfium_expected = self._get_expected_size(rotation, 'pypdfium')
expected = self._get_expected_size(rotation)
assert abs(gs_img.size[0] - gs_expected[0]) <= 2, (
assert abs(gs_img.size[0] - expected[0]) <= 2, (
f"GS width at {rotation}°: {gs_img.size[0]}, "
f"expected {gs_expected[0]}"
f"expected {expected[0]}"
)
assert abs(gs_img.size[1] - gs_expected[1]) <= 2, (
assert abs(gs_img.size[1] - expected[1]) <= 2, (
f"GS height at {rotation}°: {gs_img.size[1]}, "
f"expected {gs_expected[1]}"
f"expected {expected[1]}"
)
assert abs(pdfium_img.size[0] - pdfium_expected[0]) <= 2, (
assert abs(pdfium_img.size[0] - expected[0]) <= 2, (
f"pdfium width at {rotation}°: {pdfium_img.size[0]}, "
f"expected {pdfium_expected[0]}"
f"expected {expected[0]}"
)
assert abs(pdfium_img.size[1] - pdfium_expected[1]) <= 2, (
assert abs(pdfium_img.size[1] - expected[1]) <= 2, (
f"pdfium height at {rotation}°: {pdfium_img.size[1]}, "
f"expected {pdfium_expected[1]}"
f"expected {expected[1]}"
)
+2
View File
@@ -311,6 +311,7 @@ def test_rasterize_rotates(resources, tmp_path):
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
with Image.open(img) as im:
assert im.size == (83, 200), "Image not rotated"
@@ -327,6 +328,7 @@ def test_rasterize_rotates(resources, tmp_path):
filter_vector=False,
stop_on_soft_error=True,
options=options,
use_cropbox=False,
)
assert Image.open(img).size == (200, 83), "Image not rotated"