From 3e46b039ed7a4f765a4702f37afec5ce3de82822 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 21 Dec 2025 11:29:16 -0800 Subject: [PATCH] feat: add use_cropbox parameter to align rasterizer APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ocrmypdf/_exec/ghostscript.py | 9 ++- src/ocrmypdf/_pipeline.py | 2 + src/ocrmypdf/builtin_plugins/ghostscript.py | 2 + src/ocrmypdf/builtin_plugins/pypdfium.py | 40 +++++++++++- src/ocrmypdf/pluginspec.py | 4 ++ tests/plugins/gs_raster_failure.py | 2 + tests/plugins/gs_raster_soft_error.py | 2 + tests/test_rasterizer.py | 69 ++++++++++----------- tests/test_rotation.py | 2 + 9 files changed, 93 insertions(+), 39 deletions(-) diff --git a/src/ocrmypdf/_exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py index 028d5a4b..e81db3d6 100644 --- a/src/ocrmypdf/_exec/ghostscript.py +++ b/src/ocrmypdf/_exec/ghostscript.py @@ -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 []) + [ diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index a6090064..2f2e6cd5 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -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 diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index a453bffa..8cb4270d 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -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 diff --git a/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py index 6ef4b5fe..cfcae933 100644 --- a/src/ocrmypdf/builtin_plugins/pypdfium.py +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -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: diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index 6460861b..28dd48ac 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -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 diff --git a/tests/plugins/gs_raster_failure.py b/tests/plugins/gs_raster_failure.py index 39b90226..fb7b5ecf 100644 --- a/tests/plugins/gs_raster_failure.py +++ b/tests/plugins/gs_raster_failure.py @@ -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 diff --git a/tests/plugins/gs_raster_soft_error.py b/tests/plugins/gs_raster_soft_error.py index 768688cb..8e3c81c8 100644 --- a/tests/plugins/gs_raster_soft_error.py +++ b/tests/plugins/gs_raster_soft_error.py @@ -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 diff --git a/tests/test_rasterizer.py b/tests/test_rasterizer.py index 6b3d7d7d..6fbbc4cf 100644 --- a/tests/test_rasterizer.py +++ b/tests/test_rasterizer.py @@ -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]}" ) diff --git a/tests/test_rotation.py b/tests/test_rotation.py index 1feaabd5..56ac3d85 100644 --- a/tests/test_rotation.py +++ b/tests/test_rotation.py @@ -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"