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:
co-authored by
Claude Opus 4.5
parent
ae783b4ae6
commit
3e46b039ed
@@ -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 [])
|
||||
+ [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]}"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user