# 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*']) text_showing_ops = set(["TJ", "Tj", '"', "'"]) image_ops = set(['BI', 'ID', 'EI', 'q', 'Q', 'Do', 'cm']) color_ops = set(['g', 'rg', 'k', 'cs', 'sc', 'scn']) 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)