diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index e020fe68..d14d347e 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -3,6 +3,22 @@ # v17 +## v17.6.0 + +- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask + (stencil) with a gray or colored fill color. Previously such pages were + rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered + mid-tone text into an unreadable stipple and Tesseract failed to recognize + it. The rasterizer now inspects the fill color used to paint a mask and + promotes the page to grayscale or full color as needed, so the distinction + is preserved for the OCR engine. This applies to both the Ghostscript and + pypdfium rasterizers. {issue}`1688` +- The default 1-bit raster device for Ghostscript is now ``pngmonod`` + (error-diffusion) instead of ``pngmono`` (ordered dithering). It produces + better input for OCR on faint or anti-aliased scans at negligible cost and + no change to output file size, since the rasterized image is an + intermediate that is discarded after OCR. + ## v17.5.0 - Added support for the ``end`` alias in ``--pages``, denoting the last page 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/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py index 8edab8f3..973b40c9 100644 --- a/src/ocrmypdf/builtin_plugins/pypdfium.py +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -96,7 +96,12 @@ def _render_page_to_bitmap( # Render the page to a bitmap # The scale parameter controls the resolution # Render in grayscale for mono and gray devices (better input for 1-bit conversion) - grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray') + grayscale = raster_device.lower() in ( + 'pngmono', + 'pngmonod', + 'pnggray', + 'jpeggray', + ) # Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript if not use_cropbox: @@ -157,8 +162,8 @@ def _process_image_for_output( # 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 raster_device_lower in ('pngmono', 'pngmonod'): + # Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod) if pil_image.mode != '1': if pil_image.mode not in ('L', '1'): pil_image = pil_image.convert('L') @@ -184,7 +189,15 @@ def _process_image_for_output( # pngalpha: keep RGBA as-is # Determine output format based on raster_device - png_devices = ('png', 'pngmono', 'pnggray', 'png256', 'png16m', 'pngalpha') + png_devices = ( + 'png', + 'pngmono', + 'pngmonod', + 'pnggray', + 'png256', + 'png16m', + 'pngalpha', + ) if raster_device_lower in png_devices: format_name = 'PNG' elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'): diff --git a/src/ocrmypdf/pdfinfo/__init__.py b/src/ocrmypdf/pdfinfo/__init__.py index 3257ea4e..8ee7a581 100644 --- a/src/ocrmypdf/pdfinfo/__init__.py +++ b/src/ocrmypdf/pdfinfo/__init__.py @@ -6,7 +6,7 @@ from __future__ import annotations -from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect +from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect, Ink from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo -__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"] +__all__ = ["Colorspace", "Encoding", "FloatRect", "Ink", "PageInfo", "PdfInfo"] diff --git a/src/ocrmypdf/pdfinfo/_contentstream.py b/src/ocrmypdf/pdfinfo/_contentstream.py index cde28733..e97133fe 100644 --- a/src/ocrmypdf/pdfinfo/_contentstream.py +++ b/src/ocrmypdf/pdfinfo/_contentstream.py @@ -11,11 +11,11 @@ from math import hypot, inf, isclose from typing import NamedTuple from warnings import warn -from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream +from pikepdf import Matrix, Name, Object, PdfInlineImage, parse_content_stream from ocrmypdf.exceptions import InputFileError from ocrmypdf.helpers import Resolution -from ocrmypdf.pdfinfo._types import UNIT_SQUARE +from ocrmypdf.pdfinfo._types import UNIT_SQUARE, Ink class XobjectSettings(NamedTuple): @@ -24,6 +24,7 @@ class XobjectSettings(NamedTuple): name: str shorthand: tuple[float, float, float, float, float, float] stack_depth: int + fill_ink: Ink class InlineSettings(NamedTuple): @@ -32,6 +33,7 @@ class InlineSettings(NamedTuple): iimage: PdfInlineImage shorthand: tuple[float, float, float, float, float, float] stack_depth: int + fill_ink: Ink class ContentsInfo(NamedTuple): @@ -67,6 +69,60 @@ def _is_unit_square(shorthand): return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise) +_INK_EPSILON = 1e-3 + +# Maps a fill-colorspace name (set by the `cs` operator) to a device color +# family we can classify. Names not present here (Separation, ICCBased, +# Indexed, DeviceN, Pattern, resource names like /CS0) are treated as color. +_DEVICE_FILL_SPACE = { + '/DeviceGray': 'gray', + '/CalGray': 'gray', + '/G': 'gray', + '/DeviceRGB': 'rgb', + '/CalRGB': 'rgb', + '/RGB': 'rgb', + '/DeviceCMYK': 'cmyk', + '/CMYK': 'cmyk', +} + + +def _ink_from_components(space: str, comps: list[float]) -> Ink: + """Classify a device-color fill into mono/gray/color. + + ``space`` is one of 'gray', 'rgb', 'cmyk'. Any other value is treated + conservatively as color, since we cannot prove it is achromatic. + """ + eps = _INK_EPSILON + if space == 'gray' and len(comps) == 1: + return Ink.mono if comps[0] <= eps else Ink.gray + if space == 'rgb' and len(comps) == 3: + r, g, b = comps + if max(r, g, b) <= eps: + return Ink.mono + if abs(r - g) <= eps and abs(g - b) <= eps: + return Ink.gray + return Ink.color + if space == 'cmyk' and len(comps) == 4: + c, m, y, k = comps + if c <= eps and m <= eps and y <= eps: + return Ink.mono if k <= eps else Ink.gray + return Ink.color + return Ink.color # conservative-to-color + + +def _operand_floats(operands) -> list[float] | None: + """Convert color operands to floats, or None if any is non-numeric. + + Color operators in a malformed content stream may carry the wrong number + of operands or a non-numeric operand (e.g. a Name). Returning None lets + the caller keep the prior fill state instead of raising. + """ + try: + return [float(o) for o in operands] + except (TypeError, ValueError): + return None + + def _normalize_stack(graphobjs): """Convert runs of qQ's in the stack into single graphobjs.""" for operands, operator in graphobjs: @@ -78,12 +134,15 @@ def _normalize_stack(graphobjs): yield (operands, operator) -def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): +def _interpret_contents( + contentstream: Object, initial_shorthand=UNIT_SQUARE, initial_fill_ink=Ink.mono +): """Interpret the PDF content stream. - The stack represents the state of the PDF graphics stack. We are only - interested in the current transformation matrix (CTM) so we only track - this object; a full implementation would need to track many other items. + The stack represents the state of the PDF graphics stack. We track the + current transformation matrix (CTM) and the current fill color (so that + image masks, which are painted with the fill color, can be classified); + a full implementation would need to track many other items. The CTM is initialized to the mapping from user space to device space. PDF units are 1/72". In a PDF viewer or printer this matrix is initialized @@ -102,10 +161,12 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): stack depth exceeds the spec limit and set a hard limit beyond this to bound our memory requirements. If the stack underflows behavior is undefined in the spec, but we just pretend nothing happened and leave the - CTM unchanged. + graphics state unchanged. """ stack = [] ctm = Matrix(initial_shorthand) + fill_ink = initial_fill_ink # PDF default fill color is black + fill_space = '/DeviceGray' # current fill colorspace name (for sc/scn) xobject_settings: list[XobjectSettings] = [] inline_images: list[InlineSettings] = [] name_index = defaultdict(lambda: []) @@ -114,14 +175,15 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): vector_ops = set('S s f F f* B B* b b*'.split()) text_showing_ops = set("""TJ Tj " '""".split()) image_ops = set('BI ID EI q Q Do cm'.split()) - operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops) + color_ops = set('g rg k cs sc scn'.split()) + operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops | color_ops) for n, graphobj in enumerate( _normalize_stack(parse_content_stream(contentstream, operator_whitelist)) ): operands, operator = graphobj if operator == 'q': - stack.append(ctm) + stack.append((ctm, fill_ink, fill_space)) if len(stack) > 32: # See docstring if len(stack) > 128: raise RuntimeError( @@ -130,9 +192,9 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): warn("PDF graphics stack overflowed spec limit") elif operator == 'Q': try: - ctm = stack.pop() + ctm, fill_ink, fill_space = stack.pop() except IndexError: - # Keeping the ctm the same seems to be the only sensible thing + # Keeping the state the same seems to be the only sensible thing # to do. Just pretend nothing happened, keep calm and carry on. warn("PDF graphics stack underflowed - PDF may be malformed") elif operator == 'cm': @@ -143,17 +205,51 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): "PDF content stream is corrupt - this PDF is malformed. " "Use a PDF editor that is capable of visually inspecting the PDF." ) from e + elif operator == 'g': + if vals := _operand_floats(operands): + fill_ink = _ink_from_components('gray', vals) + fill_space = '/DeviceGray' + elif operator == 'rg': + if vals := _operand_floats(operands): + fill_ink = _ink_from_components('rgb', vals) + fill_space = '/DeviceRGB' + elif operator == 'k': + if vals := _operand_floats(operands): + fill_ink = _ink_from_components('cmyk', vals) + fill_space = '/DeviceCMYK' + elif operator == 'cs': + # Selecting a colorspace resets the fill color to that space's + # initial value, which is black for all device colorspaces. + fill_ink = Ink.mono + if operands: + fill_space = str(operands[0]) + elif operator in ('sc', 'scn'): + if any(isinstance(o, Name) for o in operands): + fill_ink = Ink.color # pattern fill + else: + space = _DEVICE_FILL_SPACE.get(fill_space) + vals = _operand_floats(operands) + if space is None or vals is None: + fill_ink = Ink.color # conservative for non-device space + else: + fill_ink = _ink_from_components(space, vals) elif operator == 'Do': image_name = operands[0] settings = XobjectSettings( - name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack) + name=image_name, + shorthand=ctm.shorthand, + stack_depth=len(stack), + fill_ink=fill_ink, ) xobject_settings.append(settings) name_index[str(image_name)].append(settings) elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this iimage = operands[0] inline = InlineSettings( - iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack) + iimage=iimage, + shorthand=ctm.shorthand, + stack_depth=len(stack), + fill_ink=fill_ink, ) inline_images.append(inline) elif operator in vector_ops: diff --git a/src/ocrmypdf/pdfinfo/_image.py b/src/ocrmypdf/pdfinfo/_image.py index 3ae8fef0..f7a34738 100644 --- a/src/ocrmypdf/pdfinfo/_image.py +++ b/src/ocrmypdf/pdfinfo/_image.py @@ -36,6 +36,7 @@ from ocrmypdf.pdfinfo._types import ( UNIT_SQUARE, Colorspace, Encoding, + Ink, ) logger = logging.getLogger() @@ -61,10 +62,12 @@ class ImageInfo: pdfimage: Object | None = None, inline: PdfInlineImage | None = None, shorthand=None, + fill_ink: Ink | None = None, ): """Initialize an ImageInfo.""" self._name = str(name) self._shorthand = shorthand + self._fill_ink = fill_ink pim: PdfInlineImage | PdfImage @@ -175,6 +178,17 @@ class ImageInfo: """Type of image, either 'image' or 'stencil'.""" return self._type + @property + def ink(self) -> Ink | None: + """Fill-color classification for stencil masks, else None. + + A stencil (image mask) is painted with the current fill color; this + reports whether that color is mono/gray/color so the rasterizer can + choose a device that does not discard the distinction. Non-stencil + images return None. + """ + return self._fill_ink if self._type == 'stencil' else None + @property def width(self) -> int: """Width of the image in pixels.""" @@ -249,7 +263,10 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]: """Find inline images in the contentstream.""" for n, inline in enumerate(contentsinfo.inline_images): yield ImageInfo( - name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage + name=f'inline-{n:02d}', + shorthand=inline.shorthand, + inline=inline.iimage, + fill_ink=inline.fill_ink, ) @@ -300,7 +317,12 @@ def _find_regular_images( # these from our DPI calculation for the page. continue - yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand) + yield ImageInfo( + name=draw.name, + pdfimage=pdfimage, + shorthand=draw.shorthand, + fill_ink=draw.fill_ink, + ) def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo): @@ -330,13 +352,19 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content # but in practice both Form XObjects and multiple drawing of the # same object are both very rare. ctm_shorthand = settings.shorthand + # A Form XObject inherits the graphics state (including fill color) + # in effect at the Do that draws it, so a mask painted with an + # inherited gray/color fill must carry that classification inward. yield from _process_content_streams( - pdf=pdf, container=form_xobject, shorthand=ctm_shorthand + pdf=pdf, + container=form_xobject, + shorthand=ctm_shorthand, + initial_fill_ink=settings.fill_ink, ) def _process_content_streams( - *, pdf: Pdf, container: Object, shorthand=None + *, pdf: Pdf, container: Object, shorthand=None, initial_fill_ink=Ink.mono ) -> Iterator[VectorMarker | TextMarker | ImageInfo]: """Find all individual instances of images drawn in the container. @@ -377,7 +405,7 @@ def _process_content_streams( else: return - contentsinfo = _interpret_contents(container, initial_shorthand) + contentsinfo = _interpret_contents(container, initial_shorthand, initial_fill_ink) if contentsinfo.found_vector: yield VectorMarker() diff --git a/src/ocrmypdf/pdfinfo/_types.py b/src/ocrmypdf/pdfinfo/_types.py index fa5252cd..cc98c827 100644 --- a/src/ocrmypdf/pdfinfo/_types.py +++ b/src/ocrmypdf/pdfinfo/_types.py @@ -39,6 +39,20 @@ class Encoding(Enum): flate_jpeg = auto() +class Ink(Enum): + """Classification of the fill color used to paint a stencil image mask. + + A stencil (image mask) is painted with the current fill color, so the + color depth needed to rasterize it for OCR depends on that fill color, + not on the mask's 1-bit data. + """ + + # pylint: disable=invalid-name + mono = auto() # black (or no color information to preserve) + gray = auto() # achromatic but not pure black + color = auto() # chromatic, or a fill we cannot prove is achromatic + + FloatRect = tuple[float, float, float, float] FRIENDLY_COLORSPACE: dict[str, Colorspace] = { diff --git a/src/ocrmypdf/pluginspec.py b/src/ocrmypdf/pluginspec.py index d50e03ca..fe62e8f3 100644 --- a/src/ocrmypdf/pluginspec.py +++ b/src/ocrmypdf/pluginspec.py @@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum): JPEGGRAY = 'jpeggray' JPEGCOLOR = 'jpeg' PNGMONO = 'pngmono' + PNGMONOD = 'pngmonod' PNGGRAY = 'pnggray' PNG256 = 'png256' PNG16M = 'png16m' diff --git a/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index db705877..d75949c9 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -18,8 +18,8 @@ from reportlab.pdfgen.canvas import Canvas from ocrmypdf import pdfinfo from ocrmypdf.exceptions import InputFileError from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution -from ocrmypdf.pdfinfo import Colorspace, Encoding -from ocrmypdf.pdfinfo._contentstream import _interpret_contents +from ocrmypdf.pdfinfo import Colorspace, Encoding, Ink +from ocrmypdf.pdfinfo._contentstream import _ink_from_components, _interpret_contents from ocrmypdf.pdfinfo.layout import PDFPage warnings.filterwarnings( @@ -290,3 +290,192 @@ def test_image_scale0(image_scale0): ) assert not pi.pages[0]._images[0].dpi.is_finite assert pi.pages[0].dpi == Resolution(0, 0) + + +def test_ink_enum_is_picklable(): + # ImageInfo crosses the worker-process boundary, so Ink must pickle. + for member in (Ink.mono, Ink.gray, Ink.color): + assert pickle.loads(pickle.dumps(member)) is member + + +def test_pngmonod_device_exists(): + from ocrmypdf.pluginspec import GhostscriptRasterDevice + + assert GhostscriptRasterDevice.PNGMONOD == 'pngmonod' + # PNGMONO retained for compatibility / explicit use + assert GhostscriptRasterDevice.PNGMONO == 'pngmono' + + +def _ink_of_first_xobject(body: bytes): + from ocrmypdf.pdfinfo._contentstream import _interpret_contents + + p = pikepdf.Pdf.new() + stream = pikepdf.Stream(p, body) + info = _interpret_contents(stream) + return info.xobject_settings[0].fill_ink + + +@pytest.mark.parametrize( + "body, expected", + [ + (b"/Im0 Do", 'mono'), # default fill is black + (b"0.263 0.263 0.263 rg /Im0 Do", 'gray'), + (b"0.5 g /Im0 Do", 'gray'), + (b"0 g /Im0 Do", 'mono'), + (b"0.8 0.2 0.2 rg /Im0 Do", 'color'), + (b"0 0 0 0.5 k /Im0 Do", 'gray'), + (b"0.5 0.1 0 0 k /Im0 Do", 'color'), + ], +) +def test_fill_ink_tracked_per_draw(body, expected): + assert _ink_of_first_xobject(body) is Ink[expected] + + +def test_fill_ink_non_device_colorspace_is_color(): + # cs to a non-device colorspace then scn -> conservative color + assert _ink_of_first_xobject(b"/CS0 cs 0.4 scn /Im0 Do") is Ink.color + + +def test_fill_ink_pattern_scn_is_color(): + assert _ink_of_first_xobject(b"/Pattern cs /P0 scn /Im0 Do") is Ink.color + + +def test_fill_ink_respects_graphics_stack(): + # Set red, save, set gray, restore -> red again at the Do + assert _ink_of_first_xobject(b"0.8 0.1 0.1 rg q 0.5 g Q /Im0 Do") is Ink.color + + +@pytest.mark.parametrize( + "body", + [ + b"g /Im0 Do", # g with no operand + b"/Foo g /Im0 Do", # g with a non-numeric operand + b"cs /Im0 Do", # cs with no operand + b"0.5 /Foo k /Im0 Do", # k with a non-numeric operand + b"/DeviceRGB cs /Foo 0.5 scn /Im0 Do", # scn with mixed bad operands + ], +) +def test_fill_ink_tolerates_malformed_color_operands(body): + # Malformed color operators must not crash the interpreter; they leave the + # fill state at its prior value (default mono) or fall back conservatively. + assert _ink_of_first_xobject(body) in (Ink.mono, Ink.color) + + +@pytest.mark.parametrize( + "space, comps, expected", + [ + ('gray', [0.0], 'mono'), + ('gray', [0.263], 'gray'), + ('gray', [1.0], 'gray'), # white -> gray (harmless) + ('rgb', [0.0, 0.0, 0.0], 'mono'), + ('rgb', [0.263, 0.263, 0.263], 'gray'), + ('rgb', [0.8, 0.2, 0.2], 'color'), + ('rgb', [1.0, 1.0, 1.0], 'gray'), + ('cmyk', [0.0, 0.0, 0.0, 0.0], 'mono'), # white + ('cmyk', [0.0, 0.0, 0.0, 0.5], 'gray'), + ('cmyk', [0.5, 0.1, 0.0, 0.0], 'color'), + ('unknown', [0.5], 'color'), # conservative fallback + ], +) +def test_ink_from_components(space, comps, expected): + assert _ink_from_components(space, comps) is Ink[expected] + + +def _make_image_mask_pdf(path, content_fill: bytes): + """Build a 1-page PDF with one 8x8 image mask painted with content_fill. + + content_fill is the color operator sequence emitted before drawing the + mask, e.g. b"0.263 0.263 0.263 rg". + """ + pdf = pikepdf.Pdf.new() + pdf.add_blank_page(page_size=(72, 72)) + # 8x8 1-bpc mask, each row padded to a byte (1 byte per row). + mask_bytes = bytes([0x7E] * 8) + mask = pikepdf.Stream(pdf, mask_bytes) + 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 + + +@pytest.fixture +def mask_gray_pdf(outdir): + return _make_image_mask_pdf(outdir / 'mask_gray.pdf', b"0.263 0.263 0.263 rg") + + +@pytest.fixture +def mask_rgb_pdf(outdir): + return _make_image_mask_pdf(outdir / 'mask_rgb.pdf', b"0.8 0.2 0.2 rg") + + +@pytest.fixture +def mask_black_pdf(outdir): + return _make_image_mask_pdf(outdir / 'mask_black.pdf', b"0 g") + + +def test_imageinfo_ink_gray(mask_gray_pdf): + image = pdfinfo.PdfInfo(mask_gray_pdf)[0].images[0] + assert image.type_ == 'stencil' + assert image.ink is Ink.gray + + +def test_imageinfo_ink_color(mask_rgb_pdf): + image = pdfinfo.PdfInfo(mask_rgb_pdf)[0].images[0] + assert image.ink is Ink.color + + +def test_imageinfo_ink_black(mask_black_pdf): + image = pdfinfo.PdfInfo(mask_black_pdf)[0].images[0] + assert image.ink is Ink.mono + + +def test_imageinfo_ink_none_for_regular_image(eight_by_eight_regular_image): + image = pdfinfo.PdfInfo(eight_by_eight_regular_image)[0].images[0] + assert image.ink is None + + +def test_fill_ink_cs_resets_color_to_black(): + # `cs` resets the fill color to the colorspace's initial value (black), + # so a stale color set before `cs` must not leak to the drawn mask. + assert _ink_of_first_xobject(b"0.8 0.2 0.2 rg /DeviceGray cs /Im0 Do") is Ink.mono + + +def test_imageinfo_ink_inherited_in_form_xobject(outdir): + # A mask drawn inside a Form XObject inherits the fill color set before the + # Do that paints the form; the gray classification must reach the mask. + 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 + + # Form draws the mask with no color of its own, inheriting the caller's. + form = pikepdf.Stream(pdf, b"q 72 0 0 72 0 0 cm /Im0 Do Q") + form.Type = pikepdf.Name.XObject + form.Subtype = pikepdf.Name.Form + form.BBox = [0, 0, 72, 72] + form.Resources = pikepdf.Dictionary(XObject=pikepdf.Dictionary(Im0=mask)) + + fname = pdf.pages[0].add_resource(form, pikepdf.Name.XObject) + pdf.pages[0].Contents = pikepdf.Stream( + pdf, b"0.263 0.263 0.263 rg %s Do" % bytes(fname) + ) + out = outdir / 'form_mask.pdf' + pdf.save(out) + + image = pdfinfo.PdfInfo(out)[0].images[0] + assert image.type_ == 'stencil' + assert image.ink is Ink.gray 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 diff --git a/tests/test_rasterizer.py b/tests/test_rasterizer.py index dec805c1..6011fbee 100644 --- a/tests/test_rasterizer.py +++ b/tests/test_rasterizer.py @@ -213,6 +213,95 @@ class TestRasterizerHookDirect: assert result == img assert img.exists() + @pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed") + def test_pypdfium_pngmonod_produces_1bit(self, resources, tmp_path): + """Pngmonod is treated like pngmono by pypdfium: it yields a 1-bit PNG.""" + pm = get_plugin_manager([]) + options = OcrOptions( + input_file=resources / 'graph.pdf', + output_file=tmp_path / 'out.pdf', + rasterizer='pypdfium', + ) + + img = tmp_path / 'pngmonod_test.png' + result = pm.rasterize_pdf_page( + input_file=resources / 'graph.pdf', + output_file=img, + raster_device='pngmonod', + raster_dpi=Resolution(50, 50), + page_dpi=Resolution(50, 50), + pageno=1, + rotation=0, + filter_vector=False, + stop_on_soft_error=True, + options=options, + use_cropbox=False, + ) + assert result == img + with Image.open(img) as im: + assert im.mode == '1' + + +def _make_text_mask_pdf(path, fill: bytes): + """Build a letter page with a large text image mask painted with ``fill``. + + The mask is a 1-bit stencil; ``fill`` is the color operator sequence that + sets the paint color (e.g. ``b"0.263 0.263 0.263 rg"``). With a gray fill + this reproduces issue #1688: the text is mid-gray, which is dithered into + noise if rasterized to 1-bit but reads correctly once promoted to gray. + """ + from PIL import ImageDraw, ImageFont + + w, h = 1700, 600 + im = Image.new('1', (w, h), 1) # 1 = white = "do not paint" under Decode [0 1] + draw = ImageDraw.Draw(im) + try: + font = ImageFont.truetype("DejaVuSans-Bold.ttf", 220) + except OSError: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 220 + ) + draw.text((40, 120), "TESTING", fill=0, font=font) + + packed = im.tobytes() # 1-bpc, rows byte-padded, MSB first + pdf = pikepdf.Pdf.new() + pdf.add_blank_page(page_size=(612, 792)) + mask = pikepdf.Stream(pdf, packed) + mask.Type = pikepdf.Name.XObject + mask.Subtype = pikepdf.Name.Image + mask.Width = w + mask.Height = h + 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 560 0 0 200 26 500 cm %s %s Do Q" % (fill, bytes(name)) + ) + pdf.save(path) + return path + + +@pytest.mark.parametrize("rasterizer", ['ghostscript', 'pypdfium']) +def test_gray_mask_ocrs_to_text(tmp_path, rasterizer): + """A gray-painted text mask OCRs to real text on both rasterizers (#1688).""" + if rasterizer == 'pypdfium' and not PYPDFIUM_AVAILABLE: + pytest.skip("pypdfium2 not installed") + + src = _make_text_mask_pdf(tmp_path / 'mask.pdf', b"0.263 0.263 0.263 rg") + out = tmp_path / 'out.pdf' + sidecar = tmp_path / 'out.txt' + check_ocrmypdf( + src, + out, + '--rasterizer', + rasterizer, + '--sidecar', + str(sidecar), + '--oversample', + '300', + ) + assert 'TESTING' in sidecar.read_text().upper() + def _create_gradient_image(width: int, height: int) -> Image.Image: """Create an image with multiple gradients to detect rasterization errors.