diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 2fcf0254..e789cbac 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -44,7 +44,7 @@ from ocrmypdf.pdfa import ( generate_pdfa_ps, speculative_pdfa_conversion, ) -from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo +from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, Ink, PageInfo, PdfInfo from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence try: @@ -508,6 +508,49 @@ def calculate_raster_dpi(page_context: PageContext): return canvas_dpi, page_dpi +def _select_raster_device(pageinfo: PageInfo) -> GhostscriptRasterDevice: + """Choose the minimum raster device that preserves the page's color depth. + + The device escalates from 1-bit mono through grayscale, indexed, and full + color as required by the page's images, image masks, and vector content. + Image masks are painted with the current fill color, so a mask painted in + gray or color escalates the device even though the mask itself is 1-bit. + """ + colorspaces = [ + GhostscriptRasterDevice.PNGMONOD, + GhostscriptRasterDevice.PNGGRAY, + GhostscriptRasterDevice.PNG256, + GhostscriptRasterDevice.PNG16M, + ] + device_idx = 0 + + def at_least(colorspace): + return max(device_idx, colorspaces.index(colorspace)) + + for image in pageinfo.images: + if image.type_ == 'stencil': + # The fill color used to paint the mask, not the 1-bit mask data, + # determines the color depth OCR needs. + if image.ink == Ink.color: + device_idx = at_least(GhostscriptRasterDevice.PNG16M) + elif image.ink == Ink.gray: + device_idx = at_least(GhostscriptRasterDevice.PNGGRAY) + continue + if image.bpc > 1: + if image.color == Colorspace.index: + device_idx = at_least(GhostscriptRasterDevice.PNG256) + elif image.color == Colorspace.gray: + device_idx = at_least(GhostscriptRasterDevice.PNGGRAY) + else: + device_idx = at_least(GhostscriptRasterDevice.PNG16M) + + if pageinfo.has_vector: + log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}") + device_idx = at_least(GhostscriptRasterDevice.PNG16M) + + return colorspaces[device_idx] + + def rasterize( input_file: Path, page_context: PageContext, @@ -529,39 +572,13 @@ def rasterize( Returns: Path: The output PNG file path. """ - colorspaces = [ - GhostscriptRasterDevice.PNGMONO, - GhostscriptRasterDevice.PNGGRAY, - GhostscriptRasterDevice.PNG256, - GhostscriptRasterDevice.PNG16M, - ] - device_idx = 0 - if remove_vectors is None: remove_vectors = page_context.options.remove_vectors output_file = page_context.get_path(f'rasterize{output_tag}.png') pageinfo = page_context.pageinfo - def at_least(colorspace): - return max(device_idx, colorspaces.index(colorspace)) - - for image in pageinfo.images: - if image.type_ != 'image': - continue # ignore masks - if image.bpc > 1: - if image.color == Colorspace.index: - device_idx = at_least(GhostscriptRasterDevice.PNG256) - elif image.color == Colorspace.gray: - device_idx = at_least(GhostscriptRasterDevice.PNGGRAY) - else: - device_idx = at_least(GhostscriptRasterDevice.PNG16M) - - if pageinfo.has_vector: - log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}") - device_idx = at_least(GhostscriptRasterDevice.PNG16M) - - device = colorspaces[device_idx] + device = _select_raster_device(pageinfo) log.debug( f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 90bab0c7..e5adb2a2 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings from unittest.mock import Mock +import pikepdf import pytest from PIL import Image from reportlab.lib.units import inch @@ -13,8 +14,10 @@ from reportlab.lib.utils import ImageReader from reportlab.pdfgen.canvas import Canvas from ocrmypdf import _pipeline, pdfinfo +from ocrmypdf._pipeline import _select_raster_device from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo import Encoding +from ocrmypdf.pluginspec import GhostscriptRasterDevice warnings.filterwarnings( "ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval" @@ -176,3 +179,39 @@ def test_should_visible_page_image_use_jpg(encodings, expected): pageinfo = Mock() pageinfo.images = [Mock(enc=enc) for enc in encodings] assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected + + +def _make_image_mask_pdf(path, content_fill: bytes): + pdf = pikepdf.Pdf.new() + pdf.add_blank_page(page_size=(72, 72)) + mask = pikepdf.Stream(pdf, bytes([0x7E] * 8)) + mask.Type = pikepdf.Name.XObject + mask.Subtype = pikepdf.Name.Image + mask.Width = 8 + mask.Height = 8 + mask.ImageMask = True + mask.BitsPerComponent = 1 + name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject) + pdf.pages[0].Contents = pikepdf.Stream( + pdf, b"q 72 0 0 72 0 0 cm %s %s Do Q" % (content_fill, bytes(name)) + ) + pdf.save(path) + return path + + +def test_select_device_gray_mask(tmp_path): + p = _make_image_mask_pdf(tmp_path / 'g.pdf', b"0.263 0.263 0.263 rg") + pageinfo = pdfinfo.PdfInfo(p)[0] + assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGGRAY + + +def test_select_device_color_mask(tmp_path): + p = _make_image_mask_pdf(tmp_path / 'c.pdf', b"0.8 0.2 0.2 rg") + pageinfo = pdfinfo.PdfInfo(p)[0] + assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNG16M + + +def test_select_device_black_mask_stays_mono(tmp_path): + p = _make_image_mask_pdf(tmp_path / 'b.pdf', b"0 g") + pageinfo = pdfinfo.PdfInfo(p)[0] + assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGMONOD