From 7e939ad44de049c883dd602a507151dd2efe9b82 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 30 Jan 2026 00:04:02 -0800 Subject: [PATCH] Fix pypdfium rasterizer to respect raster_device colorspace 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' --- src/ocrmypdf/builtin_plugins/pypdfium.py | 50 ++++++++++++++++++------ 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py index 1c52d5fc..819e06b8 100644 --- a/src/ocrmypdf/builtin_plugins/pypdfium.py +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -101,7 +101,8 @@ def _render_page_to_bitmap( # Render the page to a bitmap # The scale parameter controls the resolution - grayscale = raster_device.lower() in ('pnggray', 'jpeggray') + # Render in grayscale for mono and gray devices (better input for 1-bit conversion) + grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray') # Calculate crop to render the appropriate box # Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript @@ -160,20 +161,43 @@ def _process_image_for_output( dpi_tuple = (float(raster_dpi.x), float(raster_dpi.y)) pil_image.info['dpi'] = dpi_tuple - # Determine output format based on raster_device - if raster_device.lower() in ('png', 'pngmono', 'pnggray', 'png16m', 'pngalpha'): - format_name = 'PNG' - elif raster_device.lower() in ('jpeg', 'jpeggray', 'jpg'): - format_name = 'JPEG' - # Convert RGBA to RGB for JPEG + # 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 == 'pngmono': + # Convert to 1-bit black and white (matches Ghostscript pngmono device) + 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': - # Create white background - background = pil_image.new('RGB', pil_image.size, (255, 255, 255)) - background.paste( - pil_image, mask=pil_image.split()[-1] - ) # Use alpha channel as mask + background = Image.new('RGB', pil_image.size, (255, 255, 255)) + background.paste(pil_image, mask=pil_image.split()[-1]) pil_image = background - elif raster_device.lower() in ('tiff', 'tif'): + 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', 'pnggray', 'png256', 'png16m', 'pngalpha') + 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