Files

731 lines
27 KiB
Python

"""Functions to convert an image XObject to an image"""
import sys
from io import BytesIO
from typing import Any, Literal, Optional, Union, cast
from .._configuration import get_configuration
from .._utils import check_if_whitespace_only, logger_warning
from ..constants import ColorSpaces, ImageAttributes, StreamAttributes
from ..constants import FilterTypes as FT
from ..errors import EmptyImageDataError, LimitReachedError, PdfReadError
from ..generic import (
ArrayObject,
DecodedStreamObject,
EncodedStreamObject,
NullObject,
TextStringObject,
is_null_or_none,
)
if sys.version_info[:2] >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
try:
from PIL import Image, UnidentifiedImageError
except ImportError:
raise ImportError(
"pillow is required to do image extraction. "
"It can be installed via 'pip install pypdf[image]'"
)
mode_str_type: TypeAlias = Literal[
"", "1", "RGB", "2bits", "4bits", "P", "L", "RGBA", "CMYK"
]
MAX_IMAGE_MODE_NESTING_DEPTH: int = 10
def _get_image_mode(
color_space: Union[str, list[Any], Any],
color_components: int,
prev_mode: mode_str_type,
depth: int = 0,
) -> tuple[mode_str_type, bool]:
"""
Returns:
Image mode, not taking into account mask (transparency).
ColorInversion is required (like for some DeviceCMYK).
"""
if depth > MAX_IMAGE_MODE_NESTING_DEPTH:
raise PdfReadError(
"Color spaces nested too deeply. If required, consider increasing MAX_IMAGE_MODE_NESTING_DEPTH."
)
if is_null_or_none(color_space):
return "", False
color_space_str: str = ""
if isinstance(color_space, str):
color_space_str = color_space
elif not isinstance(color_space, list):
raise PdfReadError(
"Cannot interpret color space", color_space
) # pragma: no cover
elif not color_space:
return "", False
elif color_space[0].startswith("/Cal"): # /CalRGB or /CalGray
color_space_str = "/Device" + color_space[0][4:]
elif color_space[0] == "/ICCBased":
icc_profile = color_space[1].get_object()
color_components = cast(int, icc_profile["/N"])
color_space_str = icc_profile.get("/Alternate", "")
elif color_space[0] == "/Indexed":
color_space_str = color_space[1].get_object()
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
if mode in ("RGB", "CMYK"):
mode = "P"
return mode, invert_color
elif color_space[0] == "/Separation":
color_space_str = color_space[2].get_object()
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
return mode, True
elif color_space[0] == "/DeviceN":
original_color_space = color_space
color_components = len(color_space[1])
color_space_str = color_space[2].get_object()
if color_space_str == "/DeviceCMYK" and color_components == 1:
if original_color_space[1][0] != "/Black":
logger_warning(
"Color %(color)s converted to Gray. Please share PDF with pypdf dev team",
source=__name__,
color=original_color_space[1][0],
)
return "L", True
mode, invert_color = _get_image_mode(
color_space_str, color_components, prev_mode, depth + 1
)
return mode, invert_color
mode_map: dict[str, mode_str_type] = {
"1bit": "1", # must be zeroth position: color_components may index the values
"/DeviceGray": "L", # must be first position: color_components may index the values
"palette": "P", # must be second position: color_components may index the values
"/DeviceRGB": "RGB", # must be third position: color_components may index the values
"/DeviceCMYK": "CMYK", # must be fourth position: color_components may index the values
"2bit": "2bits",
"4bit": "4bits",
}
mode_values = list(mode_map.values())
mode = (
mode_map.get(color_space_str)
or (
mode_values[color_components]
if 0 <= color_components < len(mode_values)
else None
)
or prev_mode
)
return mode, mode == "CMYK"
def bits2byte(
data: bytes,
size: tuple[int, int],
bits: int,
colors: int = 1,
scale: bool = False,
) -> bytes:
configuration = get_configuration()
# Number of samples per row = pixels per row * components per pixel.
samples_per_row = size[0] * colors
buffer_size = samples_per_row * size[1]
if buffer_size > configuration.image_maximum_buffer_size:
raise LimitReachedError(
f"Requested buffer size {buffer_size} exceeds limit of {configuration.image_maximum_buffer_size}."
)
byte_buffer = bytearray(buffer_size)
mask = (1 << bits) - 1
# Scale a b-bit sample up to the full 0-255 range (e.g. 4-bit 15 -> 255)
# when the output is consumed directly as color (RGB) rather than as a
# palette index.
factor = 255 // mask if scale and mask else 1
required = size[1] * ((samples_per_row * bits + 7) // 8)
if (length := len(data)) < required:
logger_warning("Image data is not rectangular. Adding padding.", source=__name__)
data += b"\x00" * (required - length)
data_index = 0
bit = 8 - bits
for y in range(size[1]):
if bit != 8 - bits:
data_index += 1
bit = 8 - bits
for x in range(samples_per_row):
byte_buffer[x + y * samples_per_row] = (
((data[data_index] >> bit) & mask) * factor
)
bit -= bits
if bit < 0:
data_index += 1
bit = 8 - bits
return bytes(byte_buffer)
def _expand_low_bit_samples(
mode: mode_str_type,
size: tuple[int, int],
data: bytes,
color_space: Union[str, ArrayObject],
) -> tuple[mode_str_type, bytes]:
"""
Expand 2- or 4-bit-per-component samples to one byte per component.
Pillow has no mode for sub-byte samples, so the packed data has to be
unpacked before an image can be built from it. For a single-component
color space the samples are palette/grayscale indices ("P"). For a
multi-component space such as /DeviceRGB they are interleaved color
components, so they are also scaled to the full 0-255 range and become
an "RGB" image.
Returns the resulting mode and data, unchanged when the mode is not a
low-bit one.
"""
if mode not in ("2bits", "4bits"):
return mode, data
bits = int(mode[0])
if color_space == "/DeviceRGB":
return "RGB", bits2byte(data, size, bits, colors=3, scale=True)
return "P", bits2byte(data, size, bits)
def _image_from_bytes(
mode: str, size: tuple[int, int], data: bytes
) -> Image.Image:
pixel_count = size[0] * size[1]
bytes_per_pixel = len(mode)
required_byte_count = pixel_count * bytes_per_pixel
configuration = get_configuration()
if required_byte_count > configuration.image_maximum_buffer_size:
raise LimitReachedError(
f"Requested image buffer size {required_byte_count} exceeds limit "
f"{configuration.image_maximum_buffer_size}."
)
try:
img = Image.frombytes(mode, size, data)
except ValueError as exc:
data_length = len(data)
if data_length == 0:
raise EmptyImageDataError(
"Data is 0 bytes, cannot process an image from empty data."
) from exc
if data_length % pixel_count != 0:
raise
k = required_byte_count / data_length
data = b"".join(bytes((x,) * int(k)) for x in data)
img = Image.frombytes(mode, size, data)
return img
def __handle_flate__indexed(color_space: ArrayObject) -> tuple[Any, Any, Any, Any]:
count = len(color_space)
if count == 4:
color_space, base, hival, lookup = (value.get_object() for value in color_space)
return color_space, base, hival, lookup
# Deal with strange AutoDesk files where `base` and `hival` look like this:
# /DeviceRGB\x00255
element1 = color_space[1]
element1 = element1 if isinstance(element1, str) else element1.get_object()
if count == 3 and "\x00" in element1:
color_space, lookup = color_space[0].get_object(), color_space[2].get_object()
base, hival = element1.split("\x00")
hival = int(hival)
return color_space, base, hival, lookup
raise PdfReadError(f"Expected color space with 4 values, got {count}: {color_space}")
def _handle_flate(
size: tuple[int, int],
data: bytes,
mode: mode_str_type,
color_space: Union[str, ArrayObject],
colors: int,
obj_as_text: str,
) -> tuple[Image.Image, str, str, bool]:
"""
Process image encoded using the zlib/deflate compression method, corresponds to the FlateDecode filter
Returns img, image_format, extension, color inversion
"""
base: Any
hival: Any
lookup: Any
if isinstance(color_space, ArrayObject) and color_space[0] == "/Indexed":
color_space, base, hival, lookup = __handle_flate__indexed(color_space)
mode, data = _expand_low_bit_samples(mode, size, data, color_space)
img = _image_from_bytes(mode, size, data)
if color_space == "/Indexed":
if isinstance(lookup, (EncodedStreamObject, DecodedStreamObject)):
lookup = lookup.get_data()
if isinstance(lookup, TextStringObject):
lookup = lookup.original_bytes
if isinstance(lookup, str):
lookup = lookup.encode()
try:
nb, conv, mode = { # type: ignore[assignment]
"1": (0, "", ""),
"L": (1, "P", "L"),
"P": (0, "", ""),
"RGB": (3, "P", "RGB"),
"CMYK": (4, "P", "CMYK"),
}[_get_image_mode(base, 0, "")[0]]
except KeyError: # pragma: no cover
logger_warning(
"Base %(base)s not coded. Please share PDF with pypdf dev team",
source=__name__,
base=base,
)
lookup = None
else:
if img.mode == "1" and nb == 0:
# A two-color lookup needs at least one byte per color, but the
# base color space resolves to a 1-bit or palette image.
logger_warning(
"Cannot apply lookup for base %(base)s to image with mode 1. "
"Please share PDF with pypdf dev team",
source=__name__,
base=base,
)
elif img.mode == "1":
# Two values ("high" and "low").
expected_count = 2 * nb
actual_count = len(lookup)
if actual_count != expected_count:
if actual_count < expected_count:
logger_warning(
"Not enough lookup values: Expected %(expected_count)d, got %(actual_count)d.",
source=__name__,
expected_count=expected_count,
actual_count=actual_count,
)
lookup += bytes([0] * (expected_count - actual_count))
elif not check_if_whitespace_only(lookup[expected_count:]):
logger_warning(
"Too many lookup values: Expected %(expected_count)d, got %(actual_count)d.",
source=__name__,
expected_count=expected_count,
actual_count=actual_count,
)
lookup = lookup[:expected_count]
colors_arr = [lookup[:nb], lookup[nb:]]
source = img.convert("L")
bands = [
source.point(
[low_value] * 128 + [high_value] * 128
)
for low_value, high_value in zip(colors_arr[0], colors_arr[1])
]
img = bands[0] if nb == 1 else Image.merge(mode, bands)
else:
img = img.convert(conv)
if len(lookup) != (hival + 1) * nb:
logger_warning("Invalid Lookup Table in %(obj_as_text)s", source=__name__, obj_as_text=obj_as_text)
lookup = None
elif mode == "L":
# gray lookup does not work: it is converted to a similar RGB lookup
lookup = b"".join([bytes([b, b, b]) for b in lookup])
mode = "RGB"
# TODO: https://github.com/py-pdf/pypdf/pull/2039
# this is a work around until PIL is able to process CMYK images
elif mode == "CMYK":
_rgb = []
for _c, _m, _y, _k in (
lookup[n : n + 4] for n in range(0, 4 * (len(lookup) // 4), 4)
):
_r = int(255 * (1 - _c / 255) * (1 - _k / 255))
_g = int(255 * (1 - _m / 255) * (1 - _k / 255))
_b = int(255 * (1 - _y / 255) * (1 - _k / 255))
_rgb.append(bytes((_r, _g, _b)))
lookup = b"".join(_rgb)
mode = "RGB"
if lookup is not None:
img.putpalette(lookup, rawmode=mode)
img = img.convert("L" if base == ColorSpaces.DEVICE_GRAY else "RGB")
elif not is_null_or_none(color_space) and color_space[0] == "/ICCBased":
# Exclude pure black-and-white images.
# TODO: The remaining code still does not look correct. Shouldn't the proper way be
# to use the original image and apply the ICC transformation on it?
# For now, this just loads the original image with a different color space.
if mode != "1":
# Table 65 - Additional Entries Specific to an ICC Profile Stream Dictionary
mode2 = _get_image_mode(color_space, colors, mode)[0]
if mode != mode2:
img = Image.frombytes(mode, size, data) # reloaded as mode may have changed
if mode == "CMYK":
image_format = "TIFF"
extension = ".tif"
else:
image_format = "PNG"
extension = ".png" # mime_type: "image/png"
return img, image_format, extension, False
def _handle_jpx(
size: tuple[int, int],
data: bytes,
mode: mode_str_type,
color_space: str,
colors: int,
) -> tuple[Image.Image, str, str, bool]:
"""
Process image encoded as JPX/JPEG2000
Returns img, image_format, extension, inversion
"""
img1: Image.Image = Image.open(BytesIO(data), formats=("JPEG2000",))
mode, invert_color = _get_image_mode(color_space, colors, mode)
if mode == "":
mode = cast(mode_str_type, img1.mode)
invert_color = mode == "CMYK"
if img1.mode == "RGBA" and mode == "RGB":
mode = "RGBA"
# we need to convert to the good mode
if img1.mode == mode or {img1.mode, mode} == {"L", "P"}: # compare (unordered) sets
# L and P are indexed modes which should not be changed.
img = img1
elif {img1.mode, mode} == {"RGBA", "CMYK"}:
# RGBA / CMYK are 4bytes encoding where
# the encoding should be corrected
img = Image.frombytes(mode, img1.size, img1.tobytes())
else: # pragma: no cover
img = img1.convert(mode)
# CMYK conversion
# https://stackverflow.com/questions/38855022/
if img.mode == "CMYK" and color_space == "/ICCBased":
img = img.convert("RGB")
return img, "JPEG2000", ".jp2", invert_color
def _apply_decode(
img: Image.Image,
x_object: dict[str, Any],
lfilters: FT,
color_space: Union[str, list[Any], Any],
invert_color: bool,
) -> Image.Image:
# CMYK image and other color spaces without decode
# requires reverting scale (cf p243,2§ last sentence)
if ImageAttributes.DECODE in x_object:
decode = x_object[ImageAttributes.DECODE]
if img.mode == "CMYK" and lfilters == FT.DCT_DECODE:
# Adobe writes CMYK JPEGs with inverted component values, which is
# what the [min max] -> [1 0] remap in the branches below
# compensates for. An explicit /Decode array is expressed in terms
# of the true values, so it has to be combined with that inversion
# rather than replacing it - otherwise an identity /Decode leaves
# the image inverted (#2931).
#
# Substituting s -> 1-s into dmin + s*(dmax-dmin) gives
# dmax + s*(dmin-dmax), which is the same pair swapped. A malformed
# odd-length array is left untouched here and dropped with a
# warning further down.
decode = [
value
for index in range(0, len(decode) - 1, 2)
for value in (decode[index + 1], decode[index])
]
elif img.mode == "CMYK" and lfilters == FT.JPX_DECODE:
decode = [1.0, 0.0] if not invert_color else [0.0, 1.0]
decode = decode * len(img.getbands())
elif (img.mode == "CMYK" and lfilters == FT.DCT_DECODE) or (invert_color and img.mode == "L"):
decode = [1.0, 0.0] * len(img.getbands())
else:
decode = None
if (
isinstance(color_space, ArrayObject)
and color_space[0].get_object() == "/Indexed"
):
decode = None # decode is meaningless if Indexed
if (
isinstance(color_space, ArrayObject)
and color_space[0].get_object() == "/Separation"
):
decode = [1.0, 0.0] * len(img.getbands())
if decode is not None and (
not isinstance(decode, list) or len(decode) % 2 != 0
):
# /Decode is read straight from the image dictionary and must hold an
# even number of values (a [min max] pair per component). A malformed
# array would otherwise read past its end at decode[i + 1]; drop it and
# render the image without the remap.
logger_warning(
"Ignoring malformed /Decode array %(decode)s; expected an even number of values.",
source=__name__,
decode=decode,
)
decode = None
if decode is not None and not all(decode[i] == i % 2 for i in range(len(decode))):
lut: list[int] = []
for i in range(0, len(decode), 2):
dmin = decode[i]
dmax = decode[i + 1]
lut.extend(
round(255.0 * (j / 255.0 * (dmax - dmin) + dmin)) for j in range(256)
)
img = img.point(lut)
return img
def _get_mode_and_invert_color(
x_object: dict[str, Any], colors: int, color_space: Union[str, list[Any], Any]
) -> tuple[mode_str_type, bool]:
if (
ImageAttributes.COLOR_SPACE in x_object
and x_object[ImageAttributes.COLOR_SPACE] == ColorSpaces.DEVICE_RGB
):
# https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
mode: mode_str_type = "RGB"
if x_object.get("/BitsPerComponent", 8) < 8:
mode, invert_color = _get_image_mode(
f"{x_object.get('/BitsPerComponent', 8)}bit", 0, ""
)
else:
mode, invert_color = _get_image_mode(
color_space,
2
if (
colors == 1
and (
not is_null_or_none(color_space)
and "Gray" not in color_space
)
)
else colors,
"",
)
return mode, invert_color
def _apply_alpha(
*,
img: Image.Image,
x_object: dict[str, Any],
obj_as_text: str,
image_format: str,
extension: str,
visited: set[int],
) -> tuple[Image.Image, str, str]:
if ImageAttributes.S_MASK not in x_object:
return img, extension, image_format
s_mask = x_object[ImageAttributes.S_MASK]
if id(s_mask) in visited:
# A soft mask that refers back to an image already being
# converted would recurse until the interpreter runs out of
# stack. Such a chain cannot describe a real alpha channel, so
# drop the mask and keep the image we have.
logger_warning(
"Ignoring cyclic /SMask reference in %(obj_as_text)s",
source=__name__,
obj_as_text=obj_as_text,
)
return img, extension, image_format
alpha = _xobj_to_image(s_mask, visited=visited)[2]
if img.size != alpha.size:
logger_warning(
"Image and mask size not matching: %(image_size)s vs. %(alpha_size)s %(obj_as_text)s",
source=__name__,
image_size=img.size,
alpha_size=alpha.size,
obj_as_text=obj_as_text,
)
else:
# TODO: implement mask
if alpha.mode != "L":
alpha = alpha.convert("L")
if img.mode == "P":
img = img.convert("RGB")
elif img.mode == "1":
img = img.convert("L")
img.putalpha(alpha)
if "JPEG" in image_format:
image_format = "JPEG2000"
extension = ".jp2"
else:
image_format = "PNG"
extension = ".png"
return img, extension, image_format
def _xobj_to_image(
x_object: dict[str, Any],
pillow_parameters: Union[dict[str, Any], None] = None,
visited: Optional[set[int]] = None,
) -> tuple[Optional[str], bytes, Any]:
"""
Users need to have the pillow package installed.
It's unclear if pypdf will keep this function here, hence it's private.
It might get removed at any point.
Args:
x_object:
pillow_parameters: parameters provided to Pillow Image.save() method,
cf. <https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.Image.save>
visited: Set of id() values of XObjects already being converted higher
up the /SMask chain, used to detect cyclic soft masks.
Returns:
Tuple[file extension, bytes, PIL.Image.Image]
"""
if visited is None:
visited = set()
visited.add(id(x_object))
# For error reporting
obj_as_text = (
x_object.indirect_reference.__repr__()
if x_object is None # pragma: no cover
else x_object.__repr__()
)
# Get size and data
size = (cast(int, x_object[ImageAttributes.WIDTH]), cast(int, x_object[ImageAttributes.HEIGHT]))
data = x_object.get_data() # type: ignore[attr-defined]
if isinstance(data, str): # pragma: no cover
data = data.encode()
if len(data) % (size[0] * size[1]) == 1 and data[-1] == 0x0A: # i.e. '\n'
data = data[:-1]
# Get color properties
colors = x_object.get("/Colors", 1)
color_space: Any = x_object.get("/ColorSpace", NullObject()).get_object()
if isinstance(color_space, list) and len(color_space) == 1:
color_space = color_space[0].get_object()
mode, invert_color = _get_mode_and_invert_color(x_object, colors, color_space)
# Get filters
filters = x_object.get(StreamAttributes.FILTER, NullObject()).get_object()
# An empty array is a valid way of saying that no filter is applied: treat it
# like a missing /Filter entry rather than raising IndexError on the lookup.
last_filter = filters[-1] if filters and isinstance(filters, list) else filters
decode_parms = x_object.get(StreamAttributes.DECODE_PARMS)
if decode_parms and isinstance(decode_parms, (tuple, list)):
decode_parms = decode_parms[0]
else:
decode_parms = {}
if not isinstance(decode_parms, dict):
decode_parms = {}
extension = None
if last_filter in (FT.FLATE_DECODE, FT.RUN_LENGTH_DECODE):
img, image_format, extension, _ = _handle_flate(
size,
data,
mode,
color_space,
colors,
obj_as_text,
)
elif last_filter in (FT.LZW_DECODE, FT.ASCII_85_DECODE):
# I'm not sure if the following logic is correct.
# There might not be any relationship between the filter and the
# extension
if last_filter == FT.LZW_DECODE:
image_format = "TIFF"
extension = ".tiff" # mime_type = "image/tiff"
else:
image_format = "PNG"
extension = ".png" # mime_type = "image/png"
try:
img = Image.open(BytesIO(data), formats=("TIFF", "PNG"))
except UnidentifiedImageError:
fallback_mode, fallback_data = _expand_low_bit_samples(
mode, size, data, color_space
)
img = _image_from_bytes(fallback_mode, size, fallback_data)
elif last_filter == FT.DCT_DECODE:
img, image_format, extension = Image.open(BytesIO(data)), "JPEG", ".jpg"
# invert_color kept unchanged
elif last_filter == FT.JPX_DECODE:
img, image_format, extension, invert_color = _handle_jpx(
size, data, mode, color_space, colors
)
elif last_filter == FT.CCITT_FAX_DECODE:
img, image_format, extension, invert_color = (
Image.open(BytesIO(data), formats=("TIFF",)),
"TIFF",
".tiff",
False,
)
elif last_filter == FT.JBIG2_DECODE:
img, image_format, extension, invert_color = (
Image.open(BytesIO(data), formats=("PNG", "PPM")),
"PNG",
".png",
False,
)
elif mode == "CMYK":
img, image_format, extension, invert_color = (
_image_from_bytes(mode, size, data),
"TIFF",
".tif",
False,
)
elif mode == "":
raise PdfReadError(f"ColorSpace field not found in {x_object}")
else:
# Images without a filter (for example inline images) reach Pillow
# directly, so low-bit samples have to be expanded here as well.
mode, data = _expand_low_bit_samples(mode, size, data, color_space)
img, image_format, extension, invert_color = (
_image_from_bytes(mode, size, data),
"PNG",
".png",
False,
)
img = _apply_decode(img, x_object, last_filter, color_space, invert_color)
img, extension, image_format = _apply_alpha(
img=img, x_object=x_object, obj_as_text=obj_as_text, image_format=image_format, extension=extension,
visited=visited,
)
if pillow_parameters is None:
pillow_parameters = {}
# Preserve JPEG image quality - see issue #3515.
if image_format == "JPEG":
# This prevents: Cannot use 'keep' when original image is not a JPEG:
# "JPEG" is the value of PIL.JpegImagePlugin.JpegImageFile.format
img.format = "JPEG"
if "quality" not in pillow_parameters:
pillow_parameters["quality"] = "keep"
# Save image to bytes
img_byte_arr = BytesIO()
try:
img.save(img_byte_arr, format=image_format, **pillow_parameters)
except OSError: # pragma: no cover # covered with pillow 10.3
# in case of we convert to RGBA and then to PNG
img1 = img.convert("RGBA")
image_format = "PNG"
extension = ".png"
img_byte_arr = BytesIO()
img1.save(img_byte_arr, format=image_format)
data = img_byte_arr.getvalue()
try: # temporary try/except until other fixes of images
img = Image.open(BytesIO(data))
except Exception as exception:
logger_warning("Failed loading image: %(exception)s", source=__name__, exception=exception)
img = None # type: ignore[assignment,unused-ignore] # TODO: Remove unused-ignore on Python 3.10
return extension, data, img