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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user