feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)
This commit is contained in:
+730
@@ -0,0 +1,730 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user