From 80e77fb0212c5f0729de6273b39b447b90ed78a2 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 5 Jun 2026 00:42:38 -0700 Subject: [PATCH] feat: track image mask fill color during content stream interpretation (#1688) Track the current PDF fill color on the graphics stack alongside the CTM and record an Ink classification (mono/gray/color) per image-draw event. Image masks are painted with the current fill color, so this enables later device promotion. Color operators are tolerant of malformed operands to preserve robustness on untrusted input. --- src/ocrmypdf/pdfinfo/_contentstream.py | 88 ++++++++++++++++++++++---- tests/test_pdfinfo.py | 55 ++++++++++++++++ 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/ocrmypdf/pdfinfo/_contentstream.py b/src/ocrmypdf/pdfinfo/_contentstream.py index 5c20ad86..333c46f7 100644 --- a/src/ocrmypdf/pdfinfo/_contentstream.py +++ b/src/ocrmypdf/pdfinfo/_contentstream.py @@ -11,7 +11,7 @@ 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 @@ -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): @@ -69,6 +71,20 @@ def _is_unit_square(shorthand): _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. @@ -94,6 +110,19 @@ def _ink_from_components(space: str, comps: list[float]) -> Ink: 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: @@ -108,9 +137,10 @@ def _normalize_stack(graphobjs): def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE): """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 @@ -129,10 +159,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 = Ink.mono # 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: []) @@ -141,14 +173,17 @@ 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( @@ -157,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': @@ -170,17 +205,48 @@ 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': + 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/tests/test_pdfinfo.py b/tests/test_pdfinfo.py index 0335fbbc..d0323f5b 100644 --- a/tests/test_pdfinfo.py +++ b/tests/test_pdfinfo.py @@ -298,6 +298,61 @@ def test_ink_enum_is_picklable(): assert pickle.loads(pickle.dumps(member)) is member +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", [