Address final review findings: a mask painted inside a Form XObject now inherits the fill color in effect at the form's Do operator (previously it reset to black, missing gray/color promotion one indirection deep). The cs operator now resets the fill color to black per PDF spec, so a stale color set before cs cannot leak to a subsequently drawn mask.
328 lines
12 KiB
Python
328 lines
12 KiB
Python
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
"""PDF content stream interpretation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from collections import defaultdict
|
|
from collections.abc import Mapping
|
|
from math import hypot, inf, isclose
|
|
from typing import NamedTuple
|
|
from warnings import warn
|
|
|
|
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, Ink
|
|
|
|
|
|
class XobjectSettings(NamedTuple):
|
|
"""Info about an XObject found in a PDF."""
|
|
|
|
name: str
|
|
shorthand: tuple[float, float, float, float, float, float]
|
|
stack_depth: int
|
|
fill_ink: Ink
|
|
|
|
|
|
class InlineSettings(NamedTuple):
|
|
"""Info about an inline image found in a PDF."""
|
|
|
|
iimage: PdfInlineImage
|
|
shorthand: tuple[float, float, float, float, float, float]
|
|
stack_depth: int
|
|
fill_ink: Ink
|
|
|
|
|
|
class ContentsInfo(NamedTuple):
|
|
"""Info about various objects found in a PDF."""
|
|
|
|
xobject_settings: list[XobjectSettings]
|
|
inline_images: list[InlineSettings]
|
|
found_vector: bool
|
|
found_text: bool
|
|
name_index: Mapping[str, list[XobjectSettings]]
|
|
|
|
|
|
class TextboxInfo(NamedTuple):
|
|
"""Info about a text box found in a PDF."""
|
|
|
|
bbox: tuple[float, float, float, float]
|
|
is_visible: bool
|
|
is_corrupt: bool
|
|
|
|
|
|
class VectorMarker:
|
|
"""Sentinel indicating vector drawing operations were found on a page."""
|
|
|
|
|
|
class TextMarker:
|
|
"""Sentinel indicating text drawing operations were found on a page."""
|
|
|
|
|
|
def _is_unit_square(shorthand):
|
|
"""Check if the shorthand represents a unit square transformation."""
|
|
values = map(float, shorthand)
|
|
pairwise = zip(values, UNIT_SQUARE, strict=False)
|
|
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:
|
|
operator = str(operator)
|
|
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
|
for char in operator: # Split into individual
|
|
yield ([], char) # Yield individual
|
|
else:
|
|
yield (operands, operator)
|
|
|
|
|
|
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 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
|
|
to the transformation to device space. For example if set to
|
|
(1/72, 0, 0, 1/72, 0, 0) then all units would be calculated in inches.
|
|
|
|
Images are always considered to be (0, 0) -> (1, 1). Before drawing an
|
|
image there should be a 'cm' that sets up an image coordinate system
|
|
where drawing from (0, 0) -> (1, 1) will draw on the desired area of the
|
|
page.
|
|
|
|
PDF units suit our needs so we initialize ctm to the identity matrix.
|
|
|
|
According to the PDF specification, the maximum stack depth is 32. Other
|
|
viewers tolerate some amount beyond this. We issue a warning if the
|
|
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
|
|
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: [])
|
|
found_vector = False
|
|
found_text = False
|
|
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())
|
|
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, fill_ink, fill_space))
|
|
if len(stack) > 32: # See docstring
|
|
if len(stack) > 128:
|
|
raise RuntimeError(
|
|
f"PDF graphics stack overflowed hard limit at operator {n}"
|
|
)
|
|
warn("PDF graphics stack overflowed spec limit")
|
|
elif operator == 'Q':
|
|
try:
|
|
ctm, fill_ink, fill_space = stack.pop()
|
|
except IndexError:
|
|
# 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':
|
|
try:
|
|
ctm = Matrix(operands) @ ctm
|
|
except ValueError as e:
|
|
raise InputFileError(
|
|
"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),
|
|
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),
|
|
fill_ink=fill_ink,
|
|
)
|
|
inline_images.append(inline)
|
|
elif operator in vector_ops:
|
|
found_vector = True
|
|
elif operator in text_showing_ops:
|
|
found_text = True
|
|
|
|
return ContentsInfo(
|
|
xobject_settings=xobject_settings,
|
|
inline_images=inline_images,
|
|
found_vector=found_vector,
|
|
found_text=found_text,
|
|
name_index=name_index,
|
|
)
|
|
|
|
|
|
def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
|
"""Given the transformation matrix and image size, find the image DPI.
|
|
|
|
PDFs do not include image resolution information within image data.
|
|
Instead, the PDF page content stream describes the location where the
|
|
image will be rasterized, and the effective resolution is the ratio of the
|
|
pixel size to raster target size.
|
|
|
|
Normally a scanned PDF has the paper size set appropriately but this is
|
|
not guaranteed. The most common case is a cropped image will change the
|
|
page size (/CropBox) without altering the page content stream. That means
|
|
it is not sufficient to assume that the image fills the page, even though
|
|
that is the most common case.
|
|
|
|
A PDF image may be scaled (always), cropped, translated, rotated in place
|
|
to an arbitrary angle (rarely) and skewed. Only equal area mappings can
|
|
be expressed, that is, it is not necessary to consider distortions where
|
|
the effective DPI varies with position.
|
|
|
|
To determine the image scale, transform an offset axis vector v0 (0, 0),
|
|
width-axis vector v0 (1, 0), height-axis vector vh (0, 1) with the matrix,
|
|
which gives the dimensions of the image in PDF units. From there we can
|
|
compare to actual image dimensions. PDF uses
|
|
row vector * matrix_transposed unlike the traditional
|
|
matrix * column vector.
|
|
|
|
The offset, width and height vectors can be combined in a matrix and
|
|
multiplied by the transform matrix. Then we want to calculated
|
|
magnitude(width_vector - offset_vector)
|
|
and
|
|
magnitude(height_vector - offset_vector)
|
|
|
|
When the above is worked out algebraically, the effect of translation
|
|
cancels out, and the vector magnitudes become functions of the nonzero
|
|
transformation matrix indices. The results of the derivation are used
|
|
in this code.
|
|
|
|
pdfimages -list does calculate the DPI in some way that is not completely
|
|
naive, but it does not get the DPI of rotated images right, so cannot be
|
|
used anymore to validate this. Photoshop works, or using Acrobat to
|
|
rotate the image back to normal.
|
|
|
|
It does not matter if the image is partially cropped, or even out of the
|
|
/MediaBox.
|
|
|
|
"""
|
|
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
|
|
|
|
# Calculate the width and height of the image in PDF units
|
|
image_drawn = hypot(a, b), hypot(c, d)
|
|
|
|
def calc(drawn, pixels, inches_per_pt=72.0):
|
|
# The scale of the image is pixels per unit of default user space (1/72")
|
|
scale = pixels / drawn if drawn != 0 else inf
|
|
dpi = scale * inches_per_pt
|
|
return dpi
|
|
|
|
dpi_w, dpi_h = (calc(image_drawn[n], image_size[n]) for n in range(2))
|
|
return Resolution(dpi_w, dpi_h)
|