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.
This commit is contained in:
James R. Barlow
2026-06-05 11:40:49 -07:00
parent fa9c5b3fae
commit 80e77fb021
2 changed files with 132 additions and 11 deletions
+77 -11
View File
@@ -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:
+55
View File
@@ -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",
[