diff --git a/SketchfabPlugin.pyp b/SketchfabPlugin.pyp index 61e9482..fb2f7fb 100644 --- a/SketchfabPlugin.pyp +++ b/SketchfabPlugin.pyp @@ -53,8 +53,8 @@ class SketchfabImporter(plugins.CommandData): from sketchfab import ui_importer # Check C4D version - if c4d.GetC4DVersion() < 20000 and c4d.GeGetCurrentOS() == c4d.OPERATINGSYSTEM_WIN: - c4d.gui.MessageDialog("Sorry, but the plugin is incompatible with the version of Cinema 4D you are currently running.\n\nThe Sketchfab plugin for Windows requires\nCinema 4D R20 or greater.", c4d.GEMB_OK) + if c4d.GetC4DVersion() < 2024000 and c4d.GeGetCurrentOS() == c4d.OPERATINGSYSTEM_WIN: + c4d.gui.MessageDialog("Sorry, but the plugin is incompatible with the version of Cinema 4D you are currently running.\n\nThe Sketchfab plugin for Windows requires\nCinema 4D 2024 or greater.", c4d.GEMB_OK) return False if self.dialog is None: @@ -79,8 +79,8 @@ class SketchfabExporter(plugins.CommandData): from sketchfab import ui_exporter # Check C4D version - if c4d.GetC4DVersion() < 20000 and c4d.GeGetCurrentOS() == c4d.OPERATINGSYSTEM_WIN: - c4d.gui.MessageDialog("Sorry, but the plugin is incompatible with the version of Cinema 4D you are currently running.\n\nThe Sketchfab plugin for Windows requires\nCinema 4D R20 or greater.", c4d.GEMB_OK) + if c4d.GetC4DVersion() < 2024000 and c4d.GeGetCurrentOS() == c4d.OPERATINGSYSTEM_WIN: + c4d.gui.MessageDialog("Sorry, but the plugin is incompatible with the version of Cinema 4D you are currently running.\n\nThe Sketchfab plugin for Windows requires\nCinema 4D 2024000 or greater.", c4d.GEMB_OK) return False if self.dialog is None: diff --git a/dependencies/OSX/PIL/.dylibs/libXau.6.dylib b/dependencies/OSX/PIL/.dylibs/libXau.6.dylib index b53fa4c..94b2250 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libXau.6.dylib and b/dependencies/OSX/PIL/.dylibs/libXau.6.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libbrotlicommon.1.1.0.dylib b/dependencies/OSX/PIL/.dylibs/libbrotlicommon.1.1.0.dylib new file mode 100644 index 0000000..d9f7050 Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libbrotlicommon.1.1.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libbrotlidec.1.1.0.dylib b/dependencies/OSX/PIL/.dylibs/libbrotlidec.1.1.0.dylib new file mode 100644 index 0000000..b5e68bd Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libbrotlidec.1.1.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libfreetype.6.dylib b/dependencies/OSX/PIL/.dylibs/libfreetype.6.dylib index 0a011e7..bccf8b2 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libfreetype.6.dylib and b/dependencies/OSX/PIL/.dylibs/libfreetype.6.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libharfbuzz.0.dylib b/dependencies/OSX/PIL/.dylibs/libharfbuzz.0.dylib new file mode 100644 index 0000000..07391bc Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libharfbuzz.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libjpeg.62.4.0.dylib b/dependencies/OSX/PIL/.dylibs/libjpeg.62.4.0.dylib new file mode 100644 index 0000000..a43cee4 Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libjpeg.62.4.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libjpeg.9.dylib b/dependencies/OSX/PIL/.dylibs/libjpeg.9.dylib deleted file mode 100644 index 55ad70b..0000000 Binary files a/dependencies/OSX/PIL/.dylibs/libjpeg.9.dylib and /dev/null differ diff --git a/dependencies/OSX/PIL/.dylibs/liblcms2.2.dylib b/dependencies/OSX/PIL/.dylibs/liblcms2.2.dylib index 2d6debc..68700e8 100644 Binary files a/dependencies/OSX/PIL/.dylibs/liblcms2.2.dylib and b/dependencies/OSX/PIL/.dylibs/liblcms2.2.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/liblzma.5.dylib b/dependencies/OSX/PIL/.dylibs/liblzma.5.dylib index 9187ba8..4b8f4cb 100644 Binary files a/dependencies/OSX/PIL/.dylibs/liblzma.5.dylib and b/dependencies/OSX/PIL/.dylibs/liblzma.5.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libopenjp2.2.3.1.dylib b/dependencies/OSX/PIL/.dylibs/libopenjp2.2.3.1.dylib deleted file mode 100644 index 18873d8..0000000 Binary files a/dependencies/OSX/PIL/.dylibs/libopenjp2.2.3.1.dylib and /dev/null differ diff --git a/dependencies/OSX/PIL/.dylibs/libopenjp2.2.5.3.dylib b/dependencies/OSX/PIL/.dylibs/libopenjp2.2.5.3.dylib new file mode 100644 index 0000000..b234668 Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libopenjp2.2.5.3.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libpng16.16.dylib b/dependencies/OSX/PIL/.dylibs/libpng16.16.dylib index a7fc9bf..926029c 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libpng16.16.dylib and b/dependencies/OSX/PIL/.dylibs/libpng16.16.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libsharpyuv.0.dylib b/dependencies/OSX/PIL/.dylibs/libsharpyuv.0.dylib new file mode 100644 index 0000000..c818f02 Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libsharpyuv.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libtiff.5.dylib b/dependencies/OSX/PIL/.dylibs/libtiff.5.dylib deleted file mode 100644 index 7344907..0000000 Binary files a/dependencies/OSX/PIL/.dylibs/libtiff.5.dylib and /dev/null differ diff --git a/dependencies/OSX/PIL/.dylibs/libtiff.6.dylib b/dependencies/OSX/PIL/.dylibs/libtiff.6.dylib new file mode 100644 index 0000000..13a9758 Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libtiff.6.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libwebp.7.dylib b/dependencies/OSX/PIL/.dylibs/libwebp.7.dylib index da19c2e..fffb2e0 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libwebp.7.dylib and b/dependencies/OSX/PIL/.dylibs/libwebp.7.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libwebpdemux.2.dylib b/dependencies/OSX/PIL/.dylibs/libwebpdemux.2.dylib index af88fe2..4f35534 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libwebpdemux.2.dylib and b/dependencies/OSX/PIL/.dylibs/libwebpdemux.2.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libwebpmux.3.dylib b/dependencies/OSX/PIL/.dylibs/libwebpmux.3.dylib index 387d55a..4350534 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libwebpmux.3.dylib and b/dependencies/OSX/PIL/.dylibs/libwebpmux.3.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libxcb.1.1.0.dylib b/dependencies/OSX/PIL/.dylibs/libxcb.1.1.0.dylib index ca386f1..2a59647 100644 Binary files a/dependencies/OSX/PIL/.dylibs/libxcb.1.1.0.dylib and b/dependencies/OSX/PIL/.dylibs/libxcb.1.1.0.dylib differ diff --git a/dependencies/OSX/PIL/.dylibs/libz.1.2.11.dylib b/dependencies/OSX/PIL/.dylibs/libz.1.2.11.dylib deleted file mode 100644 index 3f4cb9a..0000000 Binary files a/dependencies/OSX/PIL/.dylibs/libz.1.2.11.dylib and /dev/null differ diff --git a/dependencies/OSX/PIL/.dylibs/libz.1.3.1.zlib-ng.dylib b/dependencies/OSX/PIL/.dylibs/libz.1.3.1.zlib-ng.dylib new file mode 100644 index 0000000..699742f Binary files /dev/null and b/dependencies/OSX/PIL/.dylibs/libz.1.3.1.zlib-ng.dylib differ diff --git a/dependencies/OSX/PIL/BdfFontFile.py b/dependencies/OSX/PIL/BdfFontFile.py index 102b72e..bc1416c 100644 --- a/dependencies/OSX/PIL/BdfFontFile.py +++ b/dependencies/OSX/PIL/BdfFontFile.py @@ -20,7 +20,9 @@ """ Parse X Bitmap Distribution Format (BDF) """ +from __future__ import annotations +from typing import BinaryIO from . import FontFile, Image @@ -36,7 +38,17 @@ bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"} -def bdf_char(f): +def bdf_char( + f: BinaryIO, +) -> ( + tuple[ + str, + int, + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]], + Image.Image, + ] + | None +): # skip to STARTCHAR while True: s = f.readline() @@ -56,24 +68,34 @@ def bdf_char(f): props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") # load bitmap - bitmap = [] + bitmap = bytearray() while True: s = f.readline() if not s or s[:7] == b"ENDCHAR": break - bitmap.append(s[:-1]) - bitmap = b"".join(bitmap) + bitmap += s[:-1] - [x, y, l, d] = [int(p) for p in props["BBX"].split()] - [dx, dy] = [int(p) for p in props["DWIDTH"].split()] + # The word BBX + # followed by the width in x (BBw), height in y (BBh), + # and x and y displacement (BBxoff0, BByoff0) + # of the lower left corner from the origin of the character. + width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split()) - bbox = (dx, dy), (l, -d - y, x + l, -d), (0, 0, x, y) + # The word DWIDTH + # followed by the width in x and y of the character in device pixels. + dwx, dwy = (int(p) for p in props["DWIDTH"].split()) + + bbox = ( + (dwx, dwy), + (x_disp, -y_disp - height, width + x_disp, -y_disp), + (0, 0, width, height), + ) try: - im = Image.frombytes("1", (x, y), bitmap, "hex", "1") + im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # deal with zero-width characters - im = Image.new("1", (x, y)) + im = Image.new("1", (width, height)) return id, int(props["ENCODING"]), bbox, im @@ -81,12 +103,13 @@ def bdf_char(f): class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp): + def __init__(self, fp: BinaryIO) -> None: super().__init__() s = fp.readline() if s[:13] != b"STARTFONT 2.1": - raise SyntaxError("not a valid BDF file") + msg = "not a valid BDF file" + raise SyntaxError(msg) props = {} comments = [] diff --git a/dependencies/OSX/PIL/BlpImagePlugin.py b/dependencies/OSX/PIL/BlpImagePlugin.py index d5d7c0e..c932b3b 100644 --- a/dependencies/OSX/PIL/BlpImagePlugin.py +++ b/dependencies/OSX/PIL/BlpImagePlugin.py @@ -29,27 +29,41 @@ - DXT5 compression is used if alpha_encoding == 7. """ +from __future__ import annotations + +import abc +import os import struct +from enum import IntEnum from io import BytesIO +from typing import IO from . import Image, ImageFile -BLP_FORMAT_JPEG = 0 -BLP_ENCODING_UNCOMPRESSED = 1 -BLP_ENCODING_DXT = 2 -BLP_ENCODING_UNCOMPRESSED_RAW_BGRA = 3 +class Format(IntEnum): + JPEG = 0 + + +class Encoding(IntEnum): + UNCOMPRESSED = 1 + DXT = 2 + UNCOMPRESSED_RAW_BGRA = 3 -BLP_ALPHA_ENCODING_DXT1 = 0 -BLP_ALPHA_ENCODING_DXT3 = 1 -BLP_ALPHA_ENCODING_DXT5 = 7 +class AlphaEncoding(IntEnum): + DXT1 = 0 + DXT3 = 1 + DXT5 = 7 -def unpack_565(i): - return (((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3) +def unpack_565(i: int) -> tuple[int, int, int]: + return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 -def decode_dxt1(data, alpha=False): + +def decode_dxt1( + data: bytes, alpha: bool = False +) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -57,9 +71,9 @@ def decode_dxt1(data, alpha=False): blocks = len(data) // 8 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): + for block_index in range(blocks): # Decode next 8-byte block. - idx = block * 8 + idx = block_index * 8 color0, color1, bits = struct.unpack_from(" tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -112,8 +126,8 @@ def decode_dxt3(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. bits = struct.unpack_from("<8B", block) @@ -157,7 +171,7 @@ def decode_dxt3(data): return ret -def decode_dxt5(data): +def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4 * width pixels) """ @@ -165,8 +179,8 @@ def decode_dxt5(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. a0, a1 = struct.unpack_from(" bool: + return prefix[:4] in (b"BLP1", b"BLP2") + + class BlpImageFile(ImageFile.ImageFile): """ Blizzard Mipmap Format @@ -239,184 +257,245 @@ class BlpImageFile(ImageFile.ImageFile): format = "BLP" format_description = "Blizzard Mipmap Format" - def _open(self): + def _open(self) -> None: self.magic = self.fp.read(4) - self._read_blp_header() + if not _accept(self.magic): + msg = f"Bad BLP magic {repr(self.magic)}" + raise BLPFormatError(msg) + compression = struct.unpack(" tuple[int, int]: try: - self.fd.seek(0) - self.magic = self.fd.read(4) - self._read_blp_header() + self._read_header() self._load() except struct.error as e: - raise OSError("Truncated Blp file") from e - return 0, 0 + msg = "Truncated BLP file" + raise OSError(msg) from e + return -1, 0 - def _read_palette(self): + @abc.abstractmethod + def _load(self) -> None: + pass + + def _read_header(self) -> None: + self._offsets = struct.unpack("<16I", self._safe_read(16 * 4)) + self._lengths = struct.unpack("<16I", self._safe_read(16 * 4)) + + def _safe_read(self, length: int) -> bytes: + assert self.fd is not None + return ImageFile._safe_read(self.fd, length) + + def _read_palette(self) -> list[tuple[int, int, int, int]]: ret = [] for i in range(256): try: - b, g, r, a = struct.unpack("<4B", self.fd.read(4)) + b, g, r, a = struct.unpack("<4B", self._safe_read(4)) except struct.error: break ret.append((b, g, r, a)) return ret - def _read_blp_header(self): - (self._blp_compression,) = struct.unpack(" bytearray: + data = bytearray() + _data = BytesIO(self._safe_read(self._lengths[0])) + while True: + try: + (offset,) = struct.unpack(" None: + self._compression, self._encoding, alpha = self.args + + if self._compression == Format.JPEG: self._decode_jpeg_stream() - elif self._blp_compression == 1: - if self._blp_encoding in (4, 5): - data = bytearray() + elif self._compression == 1: + if self._encoding in (4, 5): palette = self._read_palette() - _data = BytesIO(self.fd.read(self._blp_lengths[0])) - while True: - try: - (offset,) = struct.unpack(" None: + from .JpegImagePlugin import JpegImageFile - (jpeg_header_size,) = struct.unpack(" None: + self._compression, self._encoding, alpha, self._alpha_encoding = self.args + palette = self._read_palette() - data = bytearray() - self.fd.seek(self._blp_offsets[0]) + assert self.fd is not None + self.fd.seek(self._offsets[0]) - if self._blp_compression == 1: + if self._compression == 1: # Uncompressed or DirectX compression - if self._blp_encoding == BLP_ENCODING_UNCOMPRESSED: - _data = BytesIO(self.fd.read(self._blp_lengths[0])) - while True: - try: - (offset,) = struct.unpack(" bytes: + data = b"" + assert self.im is not None + palette = self.im.getpalette("RGBA", "RGBA") + for i in range(len(palette) // 4): + r, g, b, a = palette[i * 4 : (i + 1) * 4] + data += struct.pack("<4B", b, g, r, a) + while len(data) < 256 * 4: + data += b"\x00" * 4 + return data + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + palette_data = self._write_palette() + + offset = 20 + 16 * 4 * 2 + len(palette_data) + data = struct.pack("<16I", offset, *((0,) * 15)) + + assert self.im is not None + w, h = self.im.size + data += struct.pack("<16I", w * h, *((0,) * 15)) + + data += palette_data + for y in range(h): + for x in range(w): + data += struct.pack(" None: + if im.mode != "P": + msg = "Unsupported BLP image mode" + raise ValueError(msg) + + magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" + fp.write(magic) + + assert im.palette is not None + fp.write(struct.pack(" bool: return prefix[:2] == b"BM" -def _dib_accept(prefix): - return i32(prefix[:4]) in [12, 40, 64, 108, 124] +def _dib_accept(prefix: bytes) -> bool: + return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] # ============================================================================= # Image plugin for the Windows BMP format. # ============================================================================= class BmpImageFile(ImageFile.ImageFile): - """ Image plugin for the Windows Bitmap format (BMP) """ + """Image plugin for the Windows Bitmap format (BMP)""" # ------------------------------------------------------------- Description format_description = "Windows Bitmap" @@ -70,61 +72,71 @@ class BmpImageFile(ImageFile.ImageFile): for k, v in COMPRESSIONS.items(): vars()[k] = v - def _bitmap(self, header=0, offset=0): - """ Read relevant info about the BMP """ + def _bitmap(self, header: int = 0, offset: int = 0) -> None: + """Read relevant info about the BMP""" read, seek = self.fp.read, self.fp.seek if header: seek(header) - file_info = {} # read bmp header size @offset 14 (this is part of the header size) - file_info["header_size"] = i32(read(4)) - file_info["direction"] = -1 + file_info: dict[str, bool | int | tuple[int, ...]] = { + "header_size": i32(read(4)), + "direction": -1, + } # -------------------- If requested, read header at a specific position # read the rest of the bmp header, without its size + assert isinstance(file_info["header_size"], int) header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4) - # -------------------------------------------------- IBM OS/2 Bitmap v1 + # ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1 # ----- This format has different offsets because of width/height types + # 12: BITMAPCOREHEADER/OS21XBITMAPHEADER if file_info["header_size"] == 12: - file_info["width"] = i16(header_data[0:2]) - file_info["height"] = i16(header_data[2:4]) - file_info["planes"] = i16(header_data[4:6]) - file_info["bits"] = i16(header_data[6:8]) - file_info["compression"] = self.RAW + file_info["width"] = i16(header_data, 0) + file_info["height"] = i16(header_data, 2) + file_info["planes"] = i16(header_data, 4) + file_info["bits"] = i16(header_data, 6) + file_info["compression"] = self.COMPRESSIONS["RAW"] file_info["palette_padding"] = 3 - # --------------------------------------------- Windows Bitmap v2 to v5 - # v3, OS/2 v2, v4, v5 - elif file_info["header_size"] in (40, 64, 108, 124): - file_info["y_flip"] = i8(header_data[7]) == 0xFF + # --------------------------------------------- Windows Bitmap v3 to v5 + # 40: BITMAPINFOHEADER + # 52: BITMAPV2HEADER + # 56: BITMAPV3HEADER + # 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER + # 108: BITMAPV4HEADER + # 124: BITMAPV5HEADER + elif file_info["header_size"] in (40, 52, 56, 64, 108, 124): + file_info["y_flip"] = header_data[7] == 0xFF file_info["direction"] = 1 if file_info["y_flip"] else -1 - file_info["width"] = i32(header_data[0:4]) + file_info["width"] = i32(header_data, 0) file_info["height"] = ( - i32(header_data[4:8]) + i32(header_data, 4) if not file_info["y_flip"] - else 2 ** 32 - i32(header_data[4:8]) + else 2**32 - i32(header_data, 4) ) - file_info["planes"] = i16(header_data[8:10]) - file_info["bits"] = i16(header_data[10:12]) - file_info["compression"] = i32(header_data[12:16]) + file_info["planes"] = i16(header_data, 8) + file_info["bits"] = i16(header_data, 10) + file_info["compression"] = i32(header_data, 12) # byte size of pixel data - file_info["data_size"] = i32(header_data[16:20]) + file_info["data_size"] = i32(header_data, 16) file_info["pixels_per_meter"] = ( - i32(header_data[20:24]), - i32(header_data[24:28]), + i32(header_data, 20), + i32(header_data, 24), ) - file_info["colors"] = i32(header_data[28:32]) + file_info["colors"] = i32(header_data, 28) file_info["palette_padding"] = 4 - self.info["dpi"] = tuple( - int(x / 39.3701 + 0.5) for x in file_info["pixels_per_meter"] - ) - if file_info["compression"] == self.BITFIELDS: - if len(header_data) >= 52: - for idx, mask in enumerate( - ["r_mask", "g_mask", "b_mask", "a_mask"] - ): - file_info[mask] = i32(header_data[36 + idx * 4 : 40 + idx * 4]) + assert isinstance(file_info["pixels_per_meter"], tuple) + self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"]) + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + masks = ["r_mask", "g_mask", "b_mask"] + if len(header_data) >= 48: + if len(header_data) >= 52: + masks.append("a_mask") + else: + file_info["a_mask"] = 0x0 + for idx, mask in enumerate(masks): + file_info[mask] = i32(header_data, 36 + idx * 4) else: # 40 byte headers only have the three components in the # bitfields masks, ref: @@ -135,8 +147,12 @@ def _bitmap(self, header=0, offset=0): # location, but it is listed as a reserved component, # and it is not generally an alpha channel file_info["a_mask"] = 0x0 - for mask in ["r_mask", "g_mask", "b_mask"]: + for mask in masks: file_info[mask] = i32(read(4)) + assert isinstance(file_info["r_mask"], int) + assert isinstance(file_info["g_mask"], int) + assert isinstance(file_info["b_mask"], int) + assert isinstance(file_info["a_mask"], int) file_info["rgb_mask"] = ( file_info["r_mask"], file_info["g_mask"], @@ -149,33 +165,45 @@ def _bitmap(self, header=0, offset=0): file_info["a_mask"], ) else: - raise OSError(f"Unsupported BMP header type ({file_info['header_size']})") + msg = f"Unsupported BMP header type ({file_info['header_size']})" + raise OSError(msg) # ------------------ Special case : header is reported 40, which # ---------------------- is shorter than real size for bpp >= 16 + assert isinstance(file_info["width"], int) + assert isinstance(file_info["height"], int) self._size = file_info["width"], file_info["height"] # ------- If color count was not found in the header, compute from bits + assert isinstance(file_info["bits"], int) file_info["colors"] = ( file_info["colors"] if file_info.get("colors", 0) else (1 << file_info["bits"]) ) + assert isinstance(file_info["colors"], int) + if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: + offset += 4 * file_info["colors"] # ---------------------- Check bit depth for unusual unsupported values - self.mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) - if self.mode is None: - raise OSError(f"Unsupported BMP pixel depth ({file_info['bits']})") + self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", "")) + if not self.mode: + msg = f"Unsupported BMP pixel depth ({file_info['bits']})" + raise OSError(msg) # ---------------- Process BMP with Bitfields compression (not palette) - if file_info["compression"] == self.BITFIELDS: - SUPPORTED = { + decoder_name = "raw" + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + SUPPORTED: dict[int, list[tuple[int, ...]]] = { 32: [ (0xFF0000, 0xFF00, 0xFF, 0x0), - (0xFF0000, 0xFF00, 0xFF, 0xFF000000), + (0xFF000000, 0xFF0000, 0xFF00, 0x0), + (0xFF000000, 0xFF00, 0xFF, 0x0), + (0xFF000000, 0xFF0000, 0xFF00, 0xFF), (0xFF, 0xFF00, 0xFF0000, 0xFF000000), + (0xFF0000, 0xFF00, 0xFF, 0xFF000000), + (0xFF000000, 0xFF00, 0xFF, 0xFF0000), (0x0, 0x0, 0x0, 0x0), - (0xFF000000, 0xFF0000, 0xFF00, 0x0), ], 24: [(0xFF0000, 0xFF00, 0xFF)], 16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)], @@ -183,8 +211,11 @@ def _bitmap(self, header=0, offset=0): MASK_MODES = { (32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX", (32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR", + (32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR", + (32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR", (32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA", (32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA", + (32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR", (32, (0x0, 0x0, 0x0, 0x0)): "BGRA", (24, (0xFF0000, 0xFF00, 0xFF)): "BGR", (16, (0xF800, 0x7E0, 0x1F)): "BGR;16", @@ -195,92 +226,179 @@ def _bitmap(self, header=0, offset=0): file_info["bits"] == 32 and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgba_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] - self.mode = "RGBA" if "A" in raw_mode else self.mode + self._mode = "RGBA" if "A" in raw_mode else self.mode elif ( file_info["bits"] in (24, 16) and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgb_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] else: - raise OSError("Unsupported BMP bitfields layout") + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) else: - raise OSError("Unsupported BMP bitfields layout") - elif file_info["compression"] == self.RAW: + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) + elif file_info["compression"] == self.COMPRESSIONS["RAW"]: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset - raw_mode, self.mode = "BGRA", "RGBA" + raw_mode, self._mode = "BGRA", "RGBA" + elif file_info["compression"] in ( + self.COMPRESSIONS["RLE8"], + self.COMPRESSIONS["RLE4"], + ): + decoder_name = "bmp_rle" else: - raise OSError(f"Unsupported BMP compression ({file_info['compression']})") + msg = f"Unsupported BMP compression ({file_info['compression']})" + raise OSError(msg) # --------------- Once the header is processed, process the palette/LUT if self.mode == "P": # Paletted for 1, 4 and 8 bit images - # ---------------------------------------------------- 1-bit images if not (0 < file_info["colors"] <= 65536): - raise OSError(f"Unsupported BMP Palette size ({file_info['colors']})") + msg = f"Unsupported BMP Palette size ({file_info['colors']})" + raise OSError(msg) else: + assert isinstance(file_info["palette_padding"], int) padding = file_info["palette_padding"] palette = read(padding * file_info["colors"]) - greyscale = True + grayscale = True indices = ( (0, 255) if file_info["colors"] == 2 else list(range(file_info["colors"])) ) - # ----------------- Check if greyscale and ignore palette if so + # ----------------- Check if grayscale and ignore palette if so for ind, val in enumerate(indices): rgb = palette[ind * padding : ind * padding + 3] if rgb != o8(val) * 3: - greyscale = False + grayscale = False - # ------- If all colors are grey, white or black, ditch palette - if greyscale: - self.mode = "1" if file_info["colors"] == 2 else "L" + # ------- If all colors are gray, white or black, ditch palette + if grayscale: + self._mode = "1" if file_info["colors"] == 2 else "L" raw_mode = self.mode else: - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw( "BGRX" if padding == 4 else "BGR", palette ) # ---------------------------- Finally set the tile data for the plugin self.info["compression"] = file_info["compression"] + args: list[Any] = [raw_mode] + if decoder_name == "bmp_rle": + args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"]) + else: + assert isinstance(file_info["width"], int) + args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) + args.append(file_info["direction"]) self.tile = [ - ( - "raw", + ImageFile._Tile( + decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), - ( - raw_mode, - ((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3), - file_info["direction"], - ), + tuple(args), ) ] - def _open(self): - """ Open file, check magic number and read header """ + def _open(self) -> None: + """Open file, check magic number and read header""" # read 14 bytes: magic number, filesize, reserved, header final offset head_data = self.fp.read(14) # choke if the file does not have the required magic bytes if not _accept(head_data): - raise SyntaxError("Not a BMP file") + msg = "Not a BMP file" + raise SyntaxError(msg) # read the start position of the BMP image data (u32) - offset = i32(head_data[10:14]) + offset = i32(head_data, 10) # load bitmap information (offset=raster info) self._bitmap(offset=offset) +class BmpRleDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + rle4 = self.args[1] + data = bytearray() + x = 0 + dest_length = self.state.xsize * self.state.ysize + while len(data) < dest_length: + pixels = self.fd.read(1) + byte = self.fd.read(1) + if not pixels or not byte: + break + num_pixels = pixels[0] + if num_pixels: + # encoded mode + if x + num_pixels > self.state.xsize: + # Too much data for row + num_pixels = max(0, self.state.xsize - x) + if rle4: + first_pixel = o8(byte[0] >> 4) + second_pixel = o8(byte[0] & 0x0F) + for index in range(num_pixels): + if index % 2 == 0: + data += first_pixel + else: + data += second_pixel + else: + data += byte * num_pixels + x += num_pixels + else: + if byte[0] == 0: + # end of line + while len(data) % self.state.xsize != 0: + data += b"\x00" + x = 0 + elif byte[0] == 1: + # end of bitmap + break + elif byte[0] == 2: + # delta + bytes_read = self.fd.read(2) + if len(bytes_read) < 2: + break + right, up = self.fd.read(2) + data += b"\x00" * (right + up * self.state.xsize) + x = len(data) % self.state.xsize + else: + # absolute mode + if rle4: + # 2 pixels per byte + byte_count = byte[0] // 2 + bytes_read = self.fd.read(byte_count) + for byte_read in bytes_read: + data += o8(byte_read >> 4) + data += o8(byte_read & 0x0F) + else: + byte_count = byte[0] + bytes_read = self.fd.read(byte_count) + data += bytes_read + if len(bytes_read) < byte_count: + break + x += byte[0] + + # align to 16-bit word boundary + if self.fd.tell() % 2 != 0: + self.fd.seek(1, os.SEEK_CUR) + rawmode = "L" if self.mode == "L" else "P" + self.set_as_raw(bytes(data), rawmode, (0, self.args[-1])) + return -1, 0 + + # ============================================================================= # Image plugin for the DIB format (BMP alias) # ============================================================================= class DibImageFile(BmpImageFile): - format = "DIB" format_description = "Windows Bitmap" - def _open(self): + def _open(self) -> None: self._bitmap() @@ -298,33 +416,47 @@ def _open(self): } -def _dib_save(im, fp, filename): +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, False) -def _save(im, fp, filename, bitmap_header=True): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True +) -> None: try: rawmode, bits, colors = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as BMP") from e + msg = f"cannot write mode {im.mode} as BMP" + raise OSError(msg) from e info = im.encoderinfo dpi = info.get("dpi", (96, 96)) # 1 meter == 39.3701 inches - ppm = tuple(map(lambda x: int(x * 39.3701 + 0.5), dpi)) + ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi) stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3) header = 40 # or 64 for OS/2 version 2 image = stride * im.size[1] + if im.mode == "1": + palette = b"".join(o8(i) * 4 for i in (0, 255)) + elif im.mode == "L": + palette = b"".join(o8(i) * 4 for i in range(256)) + elif im.mode == "P": + palette = im.im.getpalette("RGB", "BGRX") + colors = len(palette) // 4 + else: + palette = None + # bitmap header if bitmap_header: offset = 14 + header + colors * 4 file_size = offset + image - if file_size > 2 ** 32 - 1: - raise ValueError("File size is too large for the BMP format") + if file_size > 2**32 - 1: + msg = "File size is too large for the BMP format" + raise ValueError(msg) fp.write( b"BM" # file type (magic) + o32(file_size) # file size @@ -349,16 +481,12 @@ def _save(im, fp, filename, bitmap_header=True): fp.write(b"\0" * (header - 40)) # padding (for OS/2 format) - if im.mode == "1": - for i in (0, 255): - fp.write(o8(i) * 4) - elif im.mode == "L": - for i in range(256): - fp.write(o8(i) * 4) - elif im.mode == "P": - fp.write(im.im.getpalette("RGB", "BGRX")) + if palette: + fp.write(palette) - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))] + ) # @@ -373,6 +501,8 @@ def _save(im, fp, filename, bitmap_header=True): Image.register_mime(BmpImageFile.format, "image/bmp") +Image.register_decoder("bmp_rle", BmpRleDecoder) + Image.register_open(DibImageFile.format, DibImageFile, _dib_accept) Image.register_save(DibImageFile.format, _dib_save) diff --git a/dependencies/OSX/PIL/BufrStubImagePlugin.py b/dependencies/OSX/PIL/BufrStubImagePlugin.py index 48f21e1..0ee2f65 100644 --- a/dependencies/OSX/PIL/BufrStubImagePlugin.py +++ b/dependencies/OSX/PIL/BufrStubImagePlugin.py @@ -8,13 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific BUFR image handler. @@ -28,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"BUFR" or prefix[:4] == b"ZCZC" class BufrStubImageFile(ImageFile.StubImageFile): - format = "BUFR" format_description = "BUFR" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(4)): - raise SyntaxError("Not a BUFR file") + msg = "Not a BUFR file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("BUFR save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "BUFR save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/OSX/PIL/ContainerIO.py b/dependencies/OSX/PIL/ContainerIO.py index 45e80b3..ec9e66c 100644 --- a/dependencies/OSX/PIL/ContainerIO.py +++ b/dependencies/OSX/PIL/ContainerIO.py @@ -13,18 +13,20 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import io +from collections.abc import Iterable +from typing import IO, AnyStr, NoReturn -class ContainerIO: +class ContainerIO(IO[AnyStr]): """ A file object that provides read access to a part of an existing file (for example a TAR file). """ - def __init__(self, file, offset, length): + def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None: """ Create file object. @@ -32,7 +34,7 @@ def __init__(self, file, offset, length): :param offset: Start of region, in bytes. :param length: Size of region, in bytes. """ - self.fh = file + self.fh: IO[AnyStr] = file self.pos = 0 self.offset = offset self.length = length @@ -41,10 +43,13 @@ def __init__(self, file, offset, length): ## # Always false. - def isatty(self): + def isatty(self) -> bool: return False - def seek(self, offset, mode=io.SEEK_SET): + def seekable(self) -> bool: + return True + + def seek(self, offset: int, mode: int = io.SEEK_SET) -> int: """ Move file pointer. @@ -52,6 +57,7 @@ def seek(self, offset, mode=io.SEEK_SET): :param mode: Starting position. Use 0 for beginning of region, 1 for current offset, and 2 for end of region. You cannot move the pointer outside the defined region. + :returns: Offset from start of region, in bytes. """ if mode == 1: self.pos = self.pos + offset @@ -62,8 +68,9 @@ def seek(self, offset, mode=io.SEEK_SET): # clamp self.pos = max(0, min(self.pos, self.length)) self.fh.seek(self.offset + self.pos) + return self.pos - def tell(self): + def tell(self) -> int: """ Get current file pointer. @@ -71,44 +78,51 @@ def tell(self): """ return self.pos - def read(self, n=0): + def readable(self) -> bool: + return True + + def read(self, n: int = -1) -> AnyStr: """ Read data. - :param n: Number of bytes to read. If omitted or zero, + :param n: Number of bytes to read. If omitted, zero or negative, read until end of region. :returns: An 8-bit string. """ - if n: + if n > 0: n = min(n, self.length - self.pos) else: n = self.length - self.pos - if not n: # EOF - return b"" if "b" in self.fh.mode else "" + if n <= 0: # EOF + return b"" if "b" in self.fh.mode else "" # type: ignore[return-value] self.pos = self.pos + n return self.fh.read(n) - def readline(self): + def readline(self, n: int = -1) -> AnyStr: """ Read a line of text. + :param n: Number of bytes to read. If omitted, zero or negative, + read until end of line. :returns: An 8-bit string. """ - s = b"" if "b" in self.fh.mode else "" + s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment] newline_character = b"\n" if "b" in self.fh.mode else "\n" while True: c = self.read(1) if not c: break s = s + c - if c == newline_character: + if c == newline_character or len(s) == n: break return s - def readlines(self): + def readlines(self, n: int | None = -1) -> list[AnyStr]: """ Read multiple lines of text. + :param n: Number of lines to read. If omitted, zero, negative or None, + read until end of region. :returns: A list of 8-bit strings. """ lines = [] @@ -117,4 +131,43 @@ def readlines(self): if not s: break lines.append(s) + if len(lines) == n: + break return lines + + def writable(self) -> bool: + return False + + def write(self, b: AnyStr) -> NoReturn: + raise NotImplementedError() + + def writelines(self, lines: Iterable[AnyStr]) -> NoReturn: + raise NotImplementedError() + + def truncate(self, size: int | None = None) -> int: + raise NotImplementedError() + + def __enter__(self) -> ContainerIO[AnyStr]: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def __iter__(self) -> ContainerIO[AnyStr]: + return self + + def __next__(self) -> AnyStr: + line = self.readline() + if not line: + msg = "end of region" + raise StopIteration(msg) + return line + + def fileno(self) -> int: + return self.fh.fileno() + + def flush(self) -> None: + self.fh.flush() + + def close(self) -> None: + self.fh.close() diff --git a/dependencies/OSX/PIL/CurImagePlugin.py b/dependencies/OSX/PIL/CurImagePlugin.py index 35123f7..c4be0ce 100644 --- a/dependencies/OSX/PIL/CurImagePlugin.py +++ b/dependencies/OSX/PIL/CurImagePlugin.py @@ -15,8 +15,9 @@ # # See the README file for information on usage and redistribution. # -from . import BmpImagePlugin, Image -from ._binary import i8 +from __future__ import annotations + +from . import BmpImagePlugin, Image, ImageFile from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -24,7 +25,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"\0\0\2\0" @@ -33,39 +34,37 @@ def _accept(prefix): class CurImageFile(BmpImagePlugin.BmpImageFile): - format = "CUR" format_description = "Windows Cursor" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() # check magic s = self.fp.read(6) if not _accept(s): - raise SyntaxError("not a CUR file") + msg = "not a CUR file" + raise SyntaxError(msg) # pick the largest cursor in the file m = b"" - for i in range(i16(s[4:])): + for i in range(i16(s, 4)): s = self.fp.read(16) if not m: m = s - elif i8(s[0]) > i8(m[0]) and i8(s[1]) > i8(m[1]): + elif s[0] > m[0] and s[1] > m[1]: m = s if not m: - raise TypeError("No cursors were found") + msg = "No cursors were found" + raise TypeError(msg) # load as bitmap - self._bitmap(i32(m[12:]) + offset) + self._bitmap(i32(m, 12) + offset) # patch up the bitmap height self._size = self.size[0], self.size[1] // 2 d, e, o, a = self.tile[0] - self.tile[0] = d, (0, 0) + self.size, o, a - - return + self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a) # diff --git a/dependencies/OSX/PIL/DcxImagePlugin.py b/dependencies/OSX/PIL/DcxImagePlugin.py index de21db8..f67f27d 100644 --- a/dependencies/OSX/PIL/DcxImagePlugin.py +++ b/dependencies/OSX/PIL/DcxImagePlugin.py @@ -20,6 +20,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image from ._binary import i32le as i32 @@ -28,7 +29,7 @@ MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 4 and i32(prefix) == MAGIC @@ -37,17 +38,16 @@ def _accept(prefix): class DcxImageFile(PcxImageFile): - format = "DCX" format_description = "Intel DCX" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # Header s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a DCX file") + msg = "not a DCX file" + raise SyntaxError(msg) # Component directory self._offset = [] @@ -57,32 +57,23 @@ def _open(self): break self._offset.append(offset) - self.__fp = self.fp - self.frame = None + self._fp = self.fp + self.frame = -1 self.n_frames = len(self._offset) self.is_animated = self.n_frames > 1 self.seek(0) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return self.frame = frame - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self._offset[frame]) PcxImageFile._open(self) - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - Image.register_open(DcxImageFile.format, DcxImageFile, _accept) diff --git a/dependencies/OSX/PIL/DdsImagePlugin.py b/dependencies/OSX/PIL/DdsImagePlugin.py index 3837192..9349e28 100644 --- a/dependencies/OSX/PIL/DdsImagePlugin.py +++ b/dependencies/OSX/PIL/DdsImagePlugin.py @@ -3,176 +3,571 @@ Jerome Leclanche Documentation: - https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt +https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: - https://creativecommons.org/publicdomain/zero/1.0/ +https://creativecommons.org/publicdomain/zero/1.0/ """ +from __future__ import annotations + +import io import struct -from io import BytesIO +import sys +from enum import IntEnum, IntFlag +from typing import IO -from . import Image, ImageFile +from . import Image, ImageFile, ImagePalette +from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o32le as o32 # Magic ("DDS ") DDS_MAGIC = 0x20534444 -# DDS flags -DDSD_CAPS = 0x1 -DDSD_HEIGHT = 0x2 -DDSD_WIDTH = 0x4 -DDSD_PITCH = 0x8 -DDSD_PIXELFORMAT = 0x1000 -DDSD_MIPMAPCOUNT = 0x20000 -DDSD_LINEARSIZE = 0x80000 -DDSD_DEPTH = 0x800000 - -# DDS caps -DDSCAPS_COMPLEX = 0x8 -DDSCAPS_TEXTURE = 0x1000 -DDSCAPS_MIPMAP = 0x400000 - -DDSCAPS2_CUBEMAP = 0x200 -DDSCAPS2_CUBEMAP_POSITIVEX = 0x400 -DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800 -DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000 -DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000 -DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000 -DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000 -DDSCAPS2_VOLUME = 0x200000 - -# Pixel Format -DDPF_ALPHAPIXELS = 0x1 -DDPF_ALPHA = 0x2 -DDPF_FOURCC = 0x4 -DDPF_PALETTEINDEXED8 = 0x20 -DDPF_RGB = 0x40 -DDPF_LUMINANCE = 0x20000 +# DDS flags +class DDSD(IntFlag): + CAPS = 0x1 + HEIGHT = 0x2 + WIDTH = 0x4 + PITCH = 0x8 + PIXELFORMAT = 0x1000 + MIPMAPCOUNT = 0x20000 + LINEARSIZE = 0x80000 + DEPTH = 0x800000 -# dds.h - -DDS_FOURCC = DDPF_FOURCC -DDS_RGB = DDPF_RGB -DDS_RGBA = DDPF_RGB | DDPF_ALPHAPIXELS -DDS_LUMINANCE = DDPF_LUMINANCE -DDS_LUMINANCEA = DDPF_LUMINANCE | DDPF_ALPHAPIXELS -DDS_ALPHA = DDPF_ALPHA -DDS_PAL8 = DDPF_PALETTEINDEXED8 - -DDS_HEADER_FLAGS_TEXTURE = DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT -DDS_HEADER_FLAGS_MIPMAP = DDSD_MIPMAPCOUNT -DDS_HEADER_FLAGS_VOLUME = DDSD_DEPTH -DDS_HEADER_FLAGS_PITCH = DDSD_PITCH -DDS_HEADER_FLAGS_LINEARSIZE = DDSD_LINEARSIZE - -DDS_HEIGHT = DDSD_HEIGHT -DDS_WIDTH = DDSD_WIDTH - -DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS_TEXTURE -DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS_COMPLEX | DDSCAPS_MIPMAP -DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS_COMPLEX -DDS_CUBEMAP_POSITIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEX -DDS_CUBEMAP_NEGATIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEX -DDS_CUBEMAP_POSITIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEY -DDS_CUBEMAP_NEGATIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEY -DDS_CUBEMAP_POSITIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEZ -DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEZ +# DDS caps +class DDSCAPS(IntFlag): + COMPLEX = 0x8 + TEXTURE = 0x1000 + MIPMAP = 0x400000 -# DXT1 -DXT1_FOURCC = 0x31545844 +class DDSCAPS2(IntFlag): + CUBEMAP = 0x200 + CUBEMAP_POSITIVEX = 0x400 + CUBEMAP_NEGATIVEX = 0x800 + CUBEMAP_POSITIVEY = 0x1000 + CUBEMAP_NEGATIVEY = 0x2000 + CUBEMAP_POSITIVEZ = 0x4000 + CUBEMAP_NEGATIVEZ = 0x8000 + VOLUME = 0x200000 -# DXT3 -DXT3_FOURCC = 0x33545844 -# DXT5 -DXT5_FOURCC = 0x35545844 +# Pixel Format +class DDPF(IntFlag): + ALPHAPIXELS = 0x1 + ALPHA = 0x2 + FOURCC = 0x4 + PALETTEINDEXED8 = 0x20 + RGB = 0x40 + LUMINANCE = 0x20000 # dxgiformat.h - -DXGI_FORMAT_BC7_TYPELESS = 97 -DXGI_FORMAT_BC7_UNORM = 98 -DXGI_FORMAT_BC7_UNORM_SRGB = 99 +class DXGI_FORMAT(IntEnum): + UNKNOWN = 0 + R32G32B32A32_TYPELESS = 1 + R32G32B32A32_FLOAT = 2 + R32G32B32A32_UINT = 3 + R32G32B32A32_SINT = 4 + R32G32B32_TYPELESS = 5 + R32G32B32_FLOAT = 6 + R32G32B32_UINT = 7 + R32G32B32_SINT = 8 + R16G16B16A16_TYPELESS = 9 + R16G16B16A16_FLOAT = 10 + R16G16B16A16_UNORM = 11 + R16G16B16A16_UINT = 12 + R16G16B16A16_SNORM = 13 + R16G16B16A16_SINT = 14 + R32G32_TYPELESS = 15 + R32G32_FLOAT = 16 + R32G32_UINT = 17 + R32G32_SINT = 18 + R32G8X24_TYPELESS = 19 + D32_FLOAT_S8X24_UINT = 20 + R32_FLOAT_X8X24_TYPELESS = 21 + X32_TYPELESS_G8X24_UINT = 22 + R10G10B10A2_TYPELESS = 23 + R10G10B10A2_UNORM = 24 + R10G10B10A2_UINT = 25 + R11G11B10_FLOAT = 26 + R8G8B8A8_TYPELESS = 27 + R8G8B8A8_UNORM = 28 + R8G8B8A8_UNORM_SRGB = 29 + R8G8B8A8_UINT = 30 + R8G8B8A8_SNORM = 31 + R8G8B8A8_SINT = 32 + R16G16_TYPELESS = 33 + R16G16_FLOAT = 34 + R16G16_UNORM = 35 + R16G16_UINT = 36 + R16G16_SNORM = 37 + R16G16_SINT = 38 + R32_TYPELESS = 39 + D32_FLOAT = 40 + R32_FLOAT = 41 + R32_UINT = 42 + R32_SINT = 43 + R24G8_TYPELESS = 44 + D24_UNORM_S8_UINT = 45 + R24_UNORM_X8_TYPELESS = 46 + X24_TYPELESS_G8_UINT = 47 + R8G8_TYPELESS = 48 + R8G8_UNORM = 49 + R8G8_UINT = 50 + R8G8_SNORM = 51 + R8G8_SINT = 52 + R16_TYPELESS = 53 + R16_FLOAT = 54 + D16_UNORM = 55 + R16_UNORM = 56 + R16_UINT = 57 + R16_SNORM = 58 + R16_SINT = 59 + R8_TYPELESS = 60 + R8_UNORM = 61 + R8_UINT = 62 + R8_SNORM = 63 + R8_SINT = 64 + A8_UNORM = 65 + R1_UNORM = 66 + R9G9B9E5_SHAREDEXP = 67 + R8G8_B8G8_UNORM = 68 + G8R8_G8B8_UNORM = 69 + BC1_TYPELESS = 70 + BC1_UNORM = 71 + BC1_UNORM_SRGB = 72 + BC2_TYPELESS = 73 + BC2_UNORM = 74 + BC2_UNORM_SRGB = 75 + BC3_TYPELESS = 76 + BC3_UNORM = 77 + BC3_UNORM_SRGB = 78 + BC4_TYPELESS = 79 + BC4_UNORM = 80 + BC4_SNORM = 81 + BC5_TYPELESS = 82 + BC5_UNORM = 83 + BC5_SNORM = 84 + B5G6R5_UNORM = 85 + B5G5R5A1_UNORM = 86 + B8G8R8A8_UNORM = 87 + B8G8R8X8_UNORM = 88 + R10G10B10_XR_BIAS_A2_UNORM = 89 + B8G8R8A8_TYPELESS = 90 + B8G8R8A8_UNORM_SRGB = 91 + B8G8R8X8_TYPELESS = 92 + B8G8R8X8_UNORM_SRGB = 93 + BC6H_TYPELESS = 94 + BC6H_UF16 = 95 + BC6H_SF16 = 96 + BC7_TYPELESS = 97 + BC7_UNORM = 98 + BC7_UNORM_SRGB = 99 + AYUV = 100 + Y410 = 101 + Y416 = 102 + NV12 = 103 + P010 = 104 + P016 = 105 + OPAQUE_420 = 106 + YUY2 = 107 + Y210 = 108 + Y216 = 109 + NV11 = 110 + AI44 = 111 + IA44 = 112 + P8 = 113 + A8P8 = 114 + B4G4R4A4_UNORM = 115 + P208 = 130 + V208 = 131 + V408 = 132 + SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189 + SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190 + + +class D3DFMT(IntEnum): + UNKNOWN = 0 + R8G8B8 = 20 + A8R8G8B8 = 21 + X8R8G8B8 = 22 + R5G6B5 = 23 + X1R5G5B5 = 24 + A1R5G5B5 = 25 + A4R4G4B4 = 26 + R3G3B2 = 27 + A8 = 28 + A8R3G3B2 = 29 + X4R4G4B4 = 30 + A2B10G10R10 = 31 + A8B8G8R8 = 32 + X8B8G8R8 = 33 + G16R16 = 34 + A2R10G10B10 = 35 + A16B16G16R16 = 36 + A8P8 = 40 + P8 = 41 + L8 = 50 + A8L8 = 51 + A4L4 = 52 + V8U8 = 60 + L6V5U5 = 61 + X8L8V8U8 = 62 + Q8W8V8U8 = 63 + V16U16 = 64 + A2W10V10U10 = 67 + D16_LOCKABLE = 70 + D32 = 71 + D15S1 = 73 + D24S8 = 75 + D24X8 = 77 + D24X4S4 = 79 + D16 = 80 + D32F_LOCKABLE = 82 + D24FS8 = 83 + D32_LOCKABLE = 84 + S8_LOCKABLE = 85 + L16 = 81 + VERTEXDATA = 100 + INDEX16 = 101 + INDEX32 = 102 + Q16W16V16U16 = 110 + R16F = 111 + G16R16F = 112 + A16B16G16R16F = 113 + R32F = 114 + G32R32F = 115 + A32B32G32R32F = 116 + CxV8U8 = 117 + A1 = 118 + A2B10G10R10_XR_BIAS = 119 + BINARYBUFFER = 199 + + UYVY = i32(b"UYVY") + R8G8_B8G8 = i32(b"RGBG") + YUY2 = i32(b"YUY2") + G8R8_G8B8 = i32(b"GRGB") + DXT1 = i32(b"DXT1") + DXT2 = i32(b"DXT2") + DXT3 = i32(b"DXT3") + DXT4 = i32(b"DXT4") + DXT5 = i32(b"DXT5") + DX10 = i32(b"DX10") + BC4S = i32(b"BC4S") + BC4U = i32(b"BC4U") + BC5S = i32(b"BC5S") + BC5U = i32(b"BC5U") + ATI1 = i32(b"ATI1") + ATI2 = i32(b"ATI2") + MULTI2_ARGB8 = i32(b"MET1") + + +# Backward compatibility layer +module = sys.modules[__name__] +for item in DDSD: + assert item.name is not None + setattr(module, f"DDSD_{item.name}", item.value) +for item1 in DDSCAPS: + assert item1.name is not None + setattr(module, f"DDSCAPS_{item1.name}", item1.value) +for item2 in DDSCAPS2: + assert item2.name is not None + setattr(module, f"DDSCAPS2_{item2.name}", item2.value) +for item3 in DDPF: + assert item3.name is not None + setattr(module, f"DDPF_{item3.name}", item3.value) + +DDS_FOURCC = DDPF.FOURCC +DDS_RGB = DDPF.RGB +DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS +DDS_LUMINANCE = DDPF.LUMINANCE +DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS +DDS_ALPHA = DDPF.ALPHA +DDS_PAL8 = DDPF.PALETTEINDEXED8 + +DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT +DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT +DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH +DDS_HEADER_FLAGS_PITCH = DDSD.PITCH +DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE + +DDS_HEIGHT = DDSD.HEIGHT +DDS_WIDTH = DDSD.WIDTH + +DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE +DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP +DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX + +DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX +DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX +DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY +DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY +DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ +DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ + +DXT1_FOURCC = D3DFMT.DXT1 +DXT3_FOURCC = D3DFMT.DXT3 +DXT5_FOURCC = D3DFMT.DXT5 + +DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS +DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM +DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB +DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS +DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM +DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM +DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16 +DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16 +DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS +DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM +DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB class DdsImageFile(ImageFile.ImageFile): format = "DDS" format_description = "DirectDraw Surface" - def _open(self): - magic, header_size = struct.unpack(" None: + if not _accept(self.fp.read(4)): + msg = "not a DDS file" + raise SyntaxError(msg) + (header_size,) = struct.unpack(" None: pass -def _validate(prefix): +class DdsRgbDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + bitcount, masks = self.args + + # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 + # Calculate how many zeros each mask is padded with + mask_offsets = [] + # And the maximum value of each channel without the padding + mask_totals = [] + for mask in masks: + offset = 0 + if mask != 0: + while mask >> (offset + 1) << (offset + 1) == mask: + offset += 1 + mask_offsets.append(offset) + mask_totals.append(mask >> offset) + + data = bytearray() + bytecount = bitcount // 8 + dest_length = self.state.xsize * self.state.ysize * len(masks) + while len(data) < dest_length: + value = int.from_bytes(self.fd.read(bytecount), "little") + for i, mask in enumerate(masks): + masked_value = value & mask + # Remove the zero padding, and scale it to 8 bits + data += o8( + int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + ) + self.set_as_raw(data) + return -1, 0 + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in ("RGB", "RGBA", "L", "LA"): + msg = f"cannot write mode {im.mode} as DDS" + raise OSError(msg) + + alpha = im.mode[-1] == "A" + if im.mode[0] == "L": + pixel_flags = DDPF.LUMINANCE + rawmode = im.mode + if alpha: + rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] + else: + rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] + else: + pixel_flags = DDPF.RGB + rawmode = im.mode[::-1] + rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] + + if alpha: + r, g, b, a = im.split() + im = Image.merge("RGBA", (a, r, g, b)) + if alpha: + pixel_flags |= DDPF.ALPHAPIXELS + rgba_mask.append(0xFF000000 if alpha else 0) + + flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT + bitcount = len(im.getbands()) * 8 + pitch = (im.width * bitcount + 7) // 8 + + fp.write( + o32(DDS_MAGIC) + + struct.pack( + "<7I", + 124, # header size + flags, # flags + im.height, + im.width, + pitch, + 0, # depth + 0, # mipmaps + ) + + struct.pack("11I", *((0,) * 11)) # reserved + # pfsize, pfflags, fourcc, bitcount + + struct.pack("<4I", 32, pixel_flags, 0, bitcount) + + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) + ) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) + + +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"DDS " -Image.register_open(DdsImageFile.format, DdsImageFile, _validate) +Image.register_open(DdsImageFile.format, DdsImageFile, _accept) +Image.register_decoder("dds_rgb", DdsRgbDecoder) +Image.register_save(DdsImageFile.format, _save) Image.register_extension(DdsImageFile.format, ".dds") diff --git a/dependencies/OSX/PIL/EpsImagePlugin.py b/dependencies/OSX/PIL/EpsImagePlugin.py index ec2329c..36ba15e 100644 --- a/dependencies/OSX/PIL/EpsImagePlugin.py +++ b/dependencies/OSX/PIL/EpsImagePlugin.py @@ -19,6 +19,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import os @@ -26,58 +27,70 @@ import subprocess import sys import tempfile +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 -# # -------------------------------------------------------------------- + split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$") field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$") +gs_binary: str | bool | None = None gs_windows_binary = None -if sys.platform.startswith("win"): - import shutil - - for binary in ("gswin32c", "gswin64c", "gs"): - if shutil.which(binary) is not None: - gs_windows_binary = binary - break - else: - gs_windows_binary = False -def has_ghostscript(): - if gs_windows_binary: - return True - if not sys.platform.startswith("win"): - try: - subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL) - return True - except OSError: - # No Ghostscript - pass - return False - +def has_ghostscript() -> bool: + global gs_binary, gs_windows_binary + if gs_binary is None: + if sys.platform.startswith("win"): + if gs_windows_binary is None: + import shutil -def Ghostscript(tile, size, fp, scale=1): + for binary in ("gswin32c", "gswin64c", "gs"): + if shutil.which(binary) is not None: + gs_windows_binary = binary + break + else: + gs_windows_binary = False + gs_binary = gs_windows_binary + else: + try: + subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL) + gs_binary = "gs" + except OSError: + gs_binary = False + return gs_binary is not False + + +def Ghostscript( + tile: list[ImageFile._Tile], + size: tuple[int, int], + fp: IO[bytes], + scale: int = 1, + transparency: bool = False, +) -> Image.core.ImagingCore: """Render an image using Ghostscript""" + global gs_binary + if not has_ghostscript(): + msg = "Unable to locate Ghostscript on paths" + raise OSError(msg) + assert isinstance(gs_binary, str) # Unpack decoder tile - decoder, tile, offset, data = tile[0] - length, bbox = data + args = tile[0].args + assert isinstance(args, tuple) + length, bbox = args # Hack to support hi-res rendering scale = int(scale) or 1 - # orig_size = size - # orig_bbox = bbox - size = (size[0] * scale, size[1] * scale) + width = size[0] * scale + height = size[1] * scale # resolution is dependent on bbox and size - res = ( - 72.0 * size[0] / (bbox[2] - bbox[0]), - 72.0 * size[1] / (bbox[3] - bbox[1]), - ) + res_x = 72.0 * width / (bbox[2] - bbox[0]) + res_y = 72.0 * height / (bbox[3] - bbox[1]) out_fd, outfile = tempfile.mkstemp() os.close(out_fd) @@ -108,16 +121,24 @@ def Ghostscript(tile, size, fp, scale=1): lengthfile -= len(s) f.write(s) + if transparency: + # "RGBA" + device = "pngalpha" + else: + # "pnmraw" automatically chooses between + # PBM ("1"), PGM ("L"), and PPM ("RGB"). + device = "pnmraw" + # Build Ghostscript command command = [ - "gs", + gs_binary, "-q", # quiet mode - "-g%dx%d" % size, # set output geometry (pixels) - "-r%fx%f" % res, # set input DPI (dots per inch) + f"-g{width:d}x{height:d}", # set output geometry (pixels) + f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch) "-dBATCH", # exit after processing "-dNOPAUSE", # don't pause between pages "-dSAFER", # safe mode - "-sDEVICE=ppmraw", # ppm driver + f"-sDEVICE={device}", f"-sOutputFile={outfile}", # output file # adjust for image origin "-c", @@ -129,11 +150,6 @@ def Ghostscript(tile, size, fp, scale=1): "showpage", ] - if gs_windows_binary is not None: - if not gs_windows_binary: - raise OSError("Unable to locate Ghostscript on paths") - command[0] = gs_windows_binary - # push data through Ghostscript try: startupinfo = None @@ -141,8 +157,9 @@ def Ghostscript(tile, size, fp, scale=1): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW subprocess.check_call(command, startupinfo=startupinfo) - out_im = Image.open(outfile) - out_im.load() + with Image.open(outfile) as out_im: + out_im.load() + return out_im.im.copy() finally: try: os.unlink(outfile) @@ -151,47 +168,13 @@ def Ghostscript(tile, size, fp, scale=1): except OSError: pass - im = out_im.im.copy() - out_im.close() - return im - - -class PSFile: - """ - Wrapper for bytesio object that treats either CR or LF as end of line. - """ - - def __init__(self, fp): - self.fp = fp - self.char = None - - def seek(self, offset, whence=io.SEEK_SET): - self.char = None - self.fp.seek(offset, whence) - - def readline(self): - s = self.char or b"" - self.char = None - c = self.fp.read(1) - while c not in b"\r\n": - s = s + c - c = self.fp.read(1) - - self.char = self.fp.read(1) - # line endings can be 1 or 2 of \r \n, in either order - if self.char in b"\r\n": - self.char = None - - return s.decode("latin-1") - - -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5) ## -# Image plugin for Encapsulated PostScript. This plugin supports only +# Image plugin for Encapsulated PostScript. This plugin supports only # a few variants of this format. @@ -203,62 +186,117 @@ class EpsImageFile(ImageFile.ImageFile): mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} - def _open(self): + def _open(self) -> None: (length, offset) = self._find_offset(self.fp) - # Rewrap the open file pointer in something that will - # convert line endings and decode to latin-1. - fp = PSFile(self.fp) - # go to offset - start of "%!PS" - fp.seek(offset) - - box = None - - self.mode = "RGB" - self._size = 1, 1 # FIXME: huh? - - # - # Load EPS header - - s_raw = fp.readline() - s = s_raw.strip("\r\n") - - while s_raw: - if s: - if len(s) > 255: - raise SyntaxError("not an EPS file") - - try: - m = split.match(s) - except re.error as e: - raise SyntaxError("not an EPS file") from e - - if m: - k, v = m.group(1, 2) - self.info[k] = v - if k == "BoundingBox": - try: - # Note: The DSC spec says that BoundingBox - # fields should be integers, but some drivers - # put floating point values there anyway. - box = [int(float(i)) for i in v.split()] - self._size = box[2] - box[0], box[3] - box[1] - self.tile = [ - ("eps", (0, 0) + self.size, offset, (length, box)) - ] - except Exception: - pass + self.fp.seek(offset) + + self._mode = "RGB" + + # When reading header comments, the first comment is used. + # When reading trailer comments, the last comment is used. + bounding_box: list[int] | None = None + imagedata_size: tuple[int, int] | None = None + + byte_arr = bytearray(255) + bytes_mv = memoryview(byte_arr) + bytes_read = 0 + reading_header_comments = True + reading_trailer_comments = False + trailer_reached = False + + def check_required_header_comments() -> None: + """ + The EPS specification requires that some headers exist. + This should be checked when the header comments formally end, + when image data starts, or when the file ends, whichever comes first. + """ + if "PS-Adobe" not in self.info: + msg = 'EPS header missing "%!PS-Adobe" comment' + raise SyntaxError(msg) + if "BoundingBox" not in self.info: + msg = 'EPS header missing "%%BoundingBox" comment' + raise SyntaxError(msg) + + def read_comment(s: str) -> bool: + nonlocal bounding_box, reading_trailer_comments + try: + m = split.match(s) + except re.error as e: + msg = "not an EPS file" + raise SyntaxError(msg) from e + + if not m: + return False + + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not bounding_box or (trailer_reached and reading_trailer_comments): + try: + # Note: The DSC spec says that BoundingBox + # fields should be integers, but some drivers + # put floating point values there anyway. + bounding_box = [int(float(i)) for i in v.split()] + except Exception: + pass + return True - else: + while True: + byte = self.fp.read(1) + if byte == b"": + # if we didn't read a byte we must be at the end of the file + if bytes_read == 0: + if reading_header_comments: + check_required_header_comments() + break + elif byte in b"\r\n": + # if we read a line ending character, ignore it and parse what + # we have already read. if we haven't read any other characters, + # continue reading + if bytes_read == 0: + continue + else: + # ASCII/hexadecimal lines in an EPS file must not exceed + # 255 characters, not including line ending characters + if bytes_read >= 255: + # only enforce this for lines starting with a "%", + # otherwise assume it's binary data + if byte_arr[0] == ord("%"): + msg = "not an EPS file" + raise SyntaxError(msg) + else: + if reading_header_comments: + check_required_header_comments() + reading_header_comments = False + # reset bytes_read so we can keep reading + # data until the end of the line + bytes_read = 0 + byte_arr[bytes_read] = byte[0] + bytes_read += 1 + continue + + if reading_header_comments: + # Load EPS header + + # if this line doesn't start with a "%", + # or does start with "%%EndComments", + # then we've reached the end of the header/comments + if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments": + check_required_header_comments() + reading_header_comments = False + continue + + s = str(bytes_mv[:bytes_read], "latin-1") + if not read_comment(s): m = field.match(s) if m: k = m.group(1) - - if k == "EndComments": - break if k[:8] == "PS-Adobe": - self.info[k[:8]] = k[9:] + self.info["PS-Adobe"] = k[9:] else: self.info[k] = "" elif s[0] == "%": @@ -266,147 +304,164 @@ def _open(self): # tools mistakenly put in the Comments section pass else: - raise OSError("bad EPS header") - - s_raw = fp.readline() - s = s_raw.strip("\r\n") - - if s and s[:1] != "%": - break - - # - # Scan for an "ImageData" descriptor - - while s[:1] == "%": - - if len(s) > 255: - raise SyntaxError("not an EPS file") - - if s[:11] == "%ImageData:": - # Encoded bitmapped image. - x, y, bi, mo = s[11:].split(None, 7)[:4] - - if int(bi) != 8: - break - try: - self.mode = self.mode_map[int(mo)] - except ValueError: + msg = "bad EPS header" + raise OSError(msg) + elif bytes_mv[:11] == b"%ImageData:": + # Check for an "ImageData" descriptor + # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096 + + # If we've already read an "ImageData" descriptor, + # don't read another one. + if imagedata_size: + bytes_read = 0 + continue + + # Values: + # columns + # rows + # bit depth (1 or 8) + # mode (1: L, 2: LAB, 3: RGB, 4: CMYK) + # number of padding channels + # block size (number of bytes per row per channel) + # binary/ascii (1: binary, 2: ascii) + # data start identifier (the image data follows after a single line + # consisting only of this quoted value) + image_data_values = byte_arr[11:bytes_read].split(None, 7) + columns, rows, bit_depth, mode_id = ( + int(value) for value in image_data_values[:4] + ) + + if bit_depth == 1: + self._mode = "1" + elif bit_depth == 8: + try: + self._mode = self.mode_map[mode_id] + except ValueError: + break + else: break - self._size = int(x), int(y) - return - - s = fp.readline().strip("\r\n") - if not s: + # Parse the columns and rows after checking the bit depth and mode + # in case the bit depth and/or mode are invalid. + imagedata_size = columns, rows + elif bytes_mv[:5] == b"%%EOF": break - - if not box: - raise OSError("cannot determine EPS bounding box") - - def _find_offset(self, fp): - - s = fp.read(160) - - if s[:4] == b"%!PS": + elif trailer_reached and reading_trailer_comments: + # Load EPS trailer + s = str(bytes_mv[:bytes_read], "latin-1") + read_comment(s) + elif bytes_mv[:9] == b"%%Trailer": + trailer_reached = True + bytes_read = 0 + + # A "BoundingBox" is always required, + # even if an "ImageData" descriptor size exists. + if not bounding_box: + msg = "cannot determine EPS bounding box" + raise OSError(msg) + + # An "ImageData" size takes precedence over the "BoundingBox". + self._size = imagedata_size or ( + bounding_box[2] - bounding_box[0], + bounding_box[3] - bounding_box[1], + ) + + self.tile = [ + ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box)) + ] + + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: + s = fp.read(4) + + if s == b"%!PS": # for HEAD without binary preview fp.seek(0, io.SEEK_END) length = fp.tell() offset = 0 - elif i32(s[0:4]) == 0xC6D3D0C5: + elif i32(s) == 0xC6D3D0C5: # FIX for: Some EPS file not handled correctly / issue #302 # EPS can contain binary data # or start directly with latin coding # more info see: # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf - offset = i32(s[4:8]) - length = i32(s[8:12]) + s = fp.read(8) + offset = i32(s) + length = i32(s, 4) else: - raise SyntaxError("not an EPS file") + msg = "not an EPS file" + raise SyntaxError(msg) - return (length, offset) + return length, offset - def load(self, scale=1): + def load( + self, scale: int = 1, transparency: bool = False + ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript - if not self.tile: - return - self.im = Ghostscript(self.tile, self.size, self.fp, scale) - self.mode = self.im.mode - self._size = self.im.size - self.tile = [] - - def load_seek(self, *args, **kwargs): + if self.tile: + self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) + self._mode = self.im.mode + self._size = self.im.size + self.tile = [] + return Image.Image.load(self) + + def load_seek(self, pos: int) -> None: # we can't incrementally load, so force ImageFile.parser to # use our custom load method by defining this method. pass -# # -------------------------------------------------------------------- -def _save(im, fp, filename, eps=1): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None: """EPS Writer for the Python Imaging Library.""" - # # make sure image data is available im.load() - # # determine PostScript image mode if im.mode == "L": - operator = (8, 1, "image") + operator = (8, 1, b"image") elif im.mode == "RGB": - operator = (8, 3, "false 3 colorimage") + operator = (8, 3, b"false 3 colorimage") elif im.mode == "CMYK": - operator = (8, 4, "false 4 colorimage") + operator = (8, 4, b"false 4 colorimage") else: - raise ValueError("image mode is not supported") - - base_fp = fp - wrapped_fp = False - if fp != sys.stdout: - fp = io.TextIOWrapper(fp, encoding="latin-1") - wrapped_fp = True - - try: - if eps: - # - # write EPS header - fp.write("%!PS-Adobe-3.0 EPSF-3.0\n") - fp.write("%%Creator: PIL 0.1 EpsEncode\n") - # fp.write("%%CreationDate: %s"...) - fp.write("%%%%BoundingBox: 0 0 %d %d\n" % im.size) - fp.write("%%Pages: 1\n") - fp.write("%%EndComments\n") - fp.write("%%Page: 1 1\n") - fp.write("%%ImageData: %d %d " % im.size) - fp.write('%d %d 0 1 1 "%s"\n' % operator) - - # - # image header - fp.write("gsave\n") - fp.write("10 dict begin\n") - fp.write(f"/buf {im.size[0] * operator[1]} string def\n") - fp.write("%d %d scale\n" % im.size) - fp.write("%d %d 8\n" % im.size) # <= bits - fp.write(f"[{im.size[0]} 0 0 -{im.size[1]} 0 {im.size[1]}]\n") - fp.write("{ currentfile buf readhexstring pop } bind\n") - fp.write(operator[2] + "\n") - if hasattr(fp, "flush"): - fp.flush() - - ImageFile._save(im, base_fp, [("eps", (0, 0) + im.size, 0, None)]) - - fp.write("\n%%%%EndBinary\n") - fp.write("grestore end\n") - if hasattr(fp, "flush"): - fp.flush() - finally: - if wrapped_fp: - fp.detach() + msg = "image mode is not supported" + raise ValueError(msg) + + if eps: + # write EPS header + fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n") + fp.write(b"%%Creator: PIL 0.1 EpsEncode\n") + # fp.write("%%CreationDate: %s"...) + fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size) + fp.write(b"%%Pages: 1\n") + fp.write(b"%%EndComments\n") + fp.write(b"%%Page: 1 1\n") + fp.write(b"%%ImageData: %d %d " % im.size) + fp.write(b'%d %d 0 1 1 "%s"\n' % operator) + + # image header + fp.write(b"gsave\n") + fp.write(b"10 dict begin\n") + fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1])) + fp.write(b"%d %d scale\n" % im.size) + fp.write(b"%d %d 8\n" % im.size) # <= bits + fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1])) + fp.write(b"{ currentfile buf readhexstring pop } bind\n") + fp.write(operator[2] + b"\n") + if hasattr(fp, "flush"): + fp.flush() + + ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)]) + + fp.write(b"\n%%%%EndBinary\n") + fp.write(b"grestore end\n") + if hasattr(fp, "flush"): + fp.flush() -# # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/ExifTags.py b/dependencies/OSX/PIL/ExifTags.py index f1c037e..2280d5c 100644 --- a/dependencies/OSX/PIL/ExifTags.py +++ b/dependencies/OSX/PIL/ExifTags.py @@ -13,306 +13,370 @@ This module provides constants and clear-text names for various well-known EXIF tags. """ +from __future__ import annotations +from enum import IntEnum -TAGS = { + +class Base(IntEnum): # possibly incomplete - 0x000B: "ProcessingSoftware", - 0x00FE: "NewSubfileType", - 0x00FF: "SubfileType", - 0x0100: "ImageWidth", - 0x0101: "ImageLength", - 0x0102: "BitsPerSample", - 0x0103: "Compression", - 0x0106: "PhotometricInterpretation", - 0x0107: "Thresholding", - 0x0108: "CellWidth", - 0x0109: "CellLength", - 0x010A: "FillOrder", - 0x010D: "DocumentName", - 0x010E: "ImageDescription", - 0x010F: "Make", - 0x0110: "Model", - 0x0111: "StripOffsets", - 0x0112: "Orientation", - 0x0115: "SamplesPerPixel", - 0x0116: "RowsPerStrip", - 0x0117: "StripByteCounts", - 0x0118: "MinSampleValue", - 0x0119: "MaxSampleValue", - 0x011A: "XResolution", - 0x011B: "YResolution", - 0x011C: "PlanarConfiguration", - 0x011D: "PageName", - 0x0120: "FreeOffsets", - 0x0121: "FreeByteCounts", - 0x0122: "GrayResponseUnit", - 0x0123: "GrayResponseCurve", - 0x0124: "T4Options", - 0x0125: "T6Options", - 0x0128: "ResolutionUnit", - 0x0129: "PageNumber", - 0x012D: "TransferFunction", - 0x0131: "Software", - 0x0132: "DateTime", - 0x013B: "Artist", - 0x013C: "HostComputer", - 0x013D: "Predictor", - 0x013E: "WhitePoint", - 0x013F: "PrimaryChromaticities", - 0x0140: "ColorMap", - 0x0141: "HalftoneHints", - 0x0142: "TileWidth", - 0x0143: "TileLength", - 0x0144: "TileOffsets", - 0x0145: "TileByteCounts", - 0x014A: "SubIFDs", - 0x014C: "InkSet", - 0x014D: "InkNames", - 0x014E: "NumberOfInks", - 0x0150: "DotRange", - 0x0151: "TargetPrinter", - 0x0152: "ExtraSamples", - 0x0153: "SampleFormat", - 0x0154: "SMinSampleValue", - 0x0155: "SMaxSampleValue", - 0x0156: "TransferRange", - 0x0157: "ClipPath", - 0x0158: "XClipPathUnits", - 0x0159: "YClipPathUnits", - 0x015A: "Indexed", - 0x015B: "JPEGTables", - 0x015F: "OPIProxy", - 0x0200: "JPEGProc", - 0x0201: "JpegIFOffset", - 0x0202: "JpegIFByteCount", - 0x0203: "JpegRestartInterval", - 0x0205: "JpegLosslessPredictors", - 0x0206: "JpegPointTransforms", - 0x0207: "JpegQTables", - 0x0208: "JpegDCTables", - 0x0209: "JpegACTables", - 0x0211: "YCbCrCoefficients", - 0x0212: "YCbCrSubSampling", - 0x0213: "YCbCrPositioning", - 0x0214: "ReferenceBlackWhite", - 0x02BC: "XMLPacket", - 0x1000: "RelatedImageFileFormat", - 0x1001: "RelatedImageWidth", - 0x1002: "RelatedImageLength", - 0x4746: "Rating", - 0x4749: "RatingPercent", - 0x800D: "ImageID", - 0x828D: "CFARepeatPatternDim", - 0x828E: "CFAPattern", - 0x828F: "BatteryLevel", - 0x8298: "Copyright", - 0x829A: "ExposureTime", - 0x829D: "FNumber", - 0x83BB: "IPTCNAA", - 0x8649: "ImageResources", - 0x8769: "ExifOffset", - 0x8773: "InterColorProfile", - 0x8822: "ExposureProgram", - 0x8824: "SpectralSensitivity", - 0x8825: "GPSInfo", - 0x8827: "ISOSpeedRatings", - 0x8828: "OECF", - 0x8829: "Interlace", - 0x882A: "TimeZoneOffset", - 0x882B: "SelfTimerMode", - 0x9000: "ExifVersion", - 0x9003: "DateTimeOriginal", - 0x9004: "DateTimeDigitized", - 0x9101: "ComponentsConfiguration", - 0x9102: "CompressedBitsPerPixel", - 0x9201: "ShutterSpeedValue", - 0x9202: "ApertureValue", - 0x9203: "BrightnessValue", - 0x9204: "ExposureBiasValue", - 0x9205: "MaxApertureValue", - 0x9206: "SubjectDistance", - 0x9207: "MeteringMode", - 0x9208: "LightSource", - 0x9209: "Flash", - 0x920A: "FocalLength", - 0x920B: "FlashEnergy", + InteropIndex = 0x0001 + ProcessingSoftware = 0x000B + NewSubfileType = 0x00FE + SubfileType = 0x00FF + ImageWidth = 0x0100 + ImageLength = 0x0101 + BitsPerSample = 0x0102 + Compression = 0x0103 + PhotometricInterpretation = 0x0106 + Thresholding = 0x0107 + CellWidth = 0x0108 + CellLength = 0x0109 + FillOrder = 0x010A + DocumentName = 0x010D + ImageDescription = 0x010E + Make = 0x010F + Model = 0x0110 + StripOffsets = 0x0111 + Orientation = 0x0112 + SamplesPerPixel = 0x0115 + RowsPerStrip = 0x0116 + StripByteCounts = 0x0117 + MinSampleValue = 0x0118 + MaxSampleValue = 0x0119 + XResolution = 0x011A + YResolution = 0x011B + PlanarConfiguration = 0x011C + PageName = 0x011D + FreeOffsets = 0x0120 + FreeByteCounts = 0x0121 + GrayResponseUnit = 0x0122 + GrayResponseCurve = 0x0123 + T4Options = 0x0124 + T6Options = 0x0125 + ResolutionUnit = 0x0128 + PageNumber = 0x0129 + TransferFunction = 0x012D + Software = 0x0131 + DateTime = 0x0132 + Artist = 0x013B + HostComputer = 0x013C + Predictor = 0x013D + WhitePoint = 0x013E + PrimaryChromaticities = 0x013F + ColorMap = 0x0140 + HalftoneHints = 0x0141 + TileWidth = 0x0142 + TileLength = 0x0143 + TileOffsets = 0x0144 + TileByteCounts = 0x0145 + SubIFDs = 0x014A + InkSet = 0x014C + InkNames = 0x014D + NumberOfInks = 0x014E + DotRange = 0x0150 + TargetPrinter = 0x0151 + ExtraSamples = 0x0152 + SampleFormat = 0x0153 + SMinSampleValue = 0x0154 + SMaxSampleValue = 0x0155 + TransferRange = 0x0156 + ClipPath = 0x0157 + XClipPathUnits = 0x0158 + YClipPathUnits = 0x0159 + Indexed = 0x015A + JPEGTables = 0x015B + OPIProxy = 0x015F + JPEGProc = 0x0200 + JpegIFOffset = 0x0201 + JpegIFByteCount = 0x0202 + JpegRestartInterval = 0x0203 + JpegLosslessPredictors = 0x0205 + JpegPointTransforms = 0x0206 + JpegQTables = 0x0207 + JpegDCTables = 0x0208 + JpegACTables = 0x0209 + YCbCrCoefficients = 0x0211 + YCbCrSubSampling = 0x0212 + YCbCrPositioning = 0x0213 + ReferenceBlackWhite = 0x0214 + XMLPacket = 0x02BC + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageLength = 0x1002 + Rating = 0x4746 + RatingPercent = 0x4749 + ImageID = 0x800D + CFARepeatPatternDim = 0x828D + BatteryLevel = 0x828F + Copyright = 0x8298 + ExposureTime = 0x829A + FNumber = 0x829D + IPTCNAA = 0x83BB + ImageResources = 0x8649 + ExifOffset = 0x8769 + InterColorProfile = 0x8773 + ExposureProgram = 0x8822 + SpectralSensitivity = 0x8824 + GPSInfo = 0x8825 + ISOSpeedRatings = 0x8827 + OECF = 0x8828 + Interlace = 0x8829 + TimeZoneOffset = 0x882A + SelfTimerMode = 0x882B + SensitivityType = 0x8830 + StandardOutputSensitivity = 0x8831 + RecommendedExposureIndex = 0x8832 + ISOSpeed = 0x8833 + ISOSpeedLatitudeyyy = 0x8834 + ISOSpeedLatitudezzz = 0x8835 + ExifVersion = 0x9000 + DateTimeOriginal = 0x9003 + DateTimeDigitized = 0x9004 + OffsetTime = 0x9010 + OffsetTimeOriginal = 0x9011 + OffsetTimeDigitized = 0x9012 + ComponentsConfiguration = 0x9101 + CompressedBitsPerPixel = 0x9102 + ShutterSpeedValue = 0x9201 + ApertureValue = 0x9202 + BrightnessValue = 0x9203 + ExposureBiasValue = 0x9204 + MaxApertureValue = 0x9205 + SubjectDistance = 0x9206 + MeteringMode = 0x9207 + LightSource = 0x9208 + Flash = 0x9209 + FocalLength = 0x920A + Noise = 0x920D + ImageNumber = 0x9211 + SecurityClassification = 0x9212 + ImageHistory = 0x9213 + TIFFEPStandardID = 0x9216 + MakerNote = 0x927C + UserComment = 0x9286 + SubsecTime = 0x9290 + SubsecTimeOriginal = 0x9291 + SubsecTimeDigitized = 0x9292 + AmbientTemperature = 0x9400 + Humidity = 0x9401 + Pressure = 0x9402 + WaterDepth = 0x9403 + Acceleration = 0x9404 + CameraElevationAngle = 0x9405 + XPTitle = 0x9C9B + XPComment = 0x9C9C + XPAuthor = 0x9C9D + XPKeywords = 0x9C9E + XPSubject = 0x9C9F + FlashPixVersion = 0xA000 + ColorSpace = 0xA001 + ExifImageWidth = 0xA002 + ExifImageHeight = 0xA003 + RelatedSoundFile = 0xA004 + ExifInteroperabilityOffset = 0xA005 + FlashEnergy = 0xA20B + SpatialFrequencyResponse = 0xA20C + FocalPlaneXResolution = 0xA20E + FocalPlaneYResolution = 0xA20F + FocalPlaneResolutionUnit = 0xA210 + SubjectLocation = 0xA214 + ExposureIndex = 0xA215 + SensingMethod = 0xA217 + FileSource = 0xA300 + SceneType = 0xA301 + CFAPattern = 0xA302 + CustomRendered = 0xA401 + ExposureMode = 0xA402 + WhiteBalance = 0xA403 + DigitalZoomRatio = 0xA404 + FocalLengthIn35mmFilm = 0xA405 + SceneCaptureType = 0xA406 + GainControl = 0xA407 + Contrast = 0xA408 + Saturation = 0xA409 + Sharpness = 0xA40A + DeviceSettingDescription = 0xA40B + SubjectDistanceRange = 0xA40C + ImageUniqueID = 0xA420 + CameraOwnerName = 0xA430 + BodySerialNumber = 0xA431 + LensSpecification = 0xA432 + LensMake = 0xA433 + LensModel = 0xA434 + LensSerialNumber = 0xA435 + CompositeImage = 0xA460 + CompositeImageCount = 0xA461 + CompositeImageExposureTimes = 0xA462 + Gamma = 0xA500 + PrintImageMatching = 0xC4A5 + DNGVersion = 0xC612 + DNGBackwardVersion = 0xC613 + UniqueCameraModel = 0xC614 + LocalizedCameraModel = 0xC615 + CFAPlaneColor = 0xC616 + CFALayout = 0xC617 + LinearizationTable = 0xC618 + BlackLevelRepeatDim = 0xC619 + BlackLevel = 0xC61A + BlackLevelDeltaH = 0xC61B + BlackLevelDeltaV = 0xC61C + WhiteLevel = 0xC61D + DefaultScale = 0xC61E + DefaultCropOrigin = 0xC61F + DefaultCropSize = 0xC620 + ColorMatrix1 = 0xC621 + ColorMatrix2 = 0xC622 + CameraCalibration1 = 0xC623 + CameraCalibration2 = 0xC624 + ReductionMatrix1 = 0xC625 + ReductionMatrix2 = 0xC626 + AnalogBalance = 0xC627 + AsShotNeutral = 0xC628 + AsShotWhiteXY = 0xC629 + BaselineExposure = 0xC62A + BaselineNoise = 0xC62B + BaselineSharpness = 0xC62C + BayerGreenSplit = 0xC62D + LinearResponseLimit = 0xC62E + CameraSerialNumber = 0xC62F + LensInfo = 0xC630 + ChromaBlurRadius = 0xC631 + AntiAliasStrength = 0xC632 + ShadowScale = 0xC633 + DNGPrivateData = 0xC634 + MakerNoteSafety = 0xC635 + CalibrationIlluminant1 = 0xC65A + CalibrationIlluminant2 = 0xC65B + BestQualityScale = 0xC65C + RawDataUniqueID = 0xC65D + OriginalRawFileName = 0xC68B + OriginalRawFileData = 0xC68C + ActiveArea = 0xC68D + MaskedAreas = 0xC68E + AsShotICCProfile = 0xC68F + AsShotPreProfileMatrix = 0xC690 + CurrentICCProfile = 0xC691 + CurrentPreProfileMatrix = 0xC692 + ColorimetricReference = 0xC6BF + CameraCalibrationSignature = 0xC6F3 + ProfileCalibrationSignature = 0xC6F4 + AsShotProfileName = 0xC6F6 + NoiseReductionApplied = 0xC6F7 + ProfileName = 0xC6F8 + ProfileHueSatMapDims = 0xC6F9 + ProfileHueSatMapData1 = 0xC6FA + ProfileHueSatMapData2 = 0xC6FB + ProfileToneCurve = 0xC6FC + ProfileEmbedPolicy = 0xC6FD + ProfileCopyright = 0xC6FE + ForwardMatrix1 = 0xC714 + ForwardMatrix2 = 0xC715 + PreviewApplicationName = 0xC716 + PreviewApplicationVersion = 0xC717 + PreviewSettingsName = 0xC718 + PreviewSettingsDigest = 0xC719 + PreviewColorSpace = 0xC71A + PreviewDateTime = 0xC71B + RawImageDigest = 0xC71C + OriginalRawFileDigest = 0xC71D + SubTileBlockSize = 0xC71E + RowInterleaveFactor = 0xC71F + ProfileLookTableDims = 0xC725 + ProfileLookTableData = 0xC726 + OpcodeList1 = 0xC740 + OpcodeList2 = 0xC741 + OpcodeList3 = 0xC74E + NoiseProfile = 0xC761 + + +"""Maps EXIF tags to tag names.""" +TAGS = { + **{i.value: i.name for i in Base}, 0x920C: "SpatialFrequencyResponse", - 0x920D: "Noise", - 0x9211: "ImageNumber", - 0x9212: "SecurityClassification", - 0x9213: "ImageHistory", 0x9214: "SubjectLocation", 0x9215: "ExposureIndex", + 0x828E: "CFAPattern", + 0x920B: "FlashEnergy", 0x9216: "TIFF/EPStandardID", - 0x927C: "MakerNote", - 0x9286: "UserComment", - 0x9290: "SubsecTime", - 0x9291: "SubsecTimeOriginal", - 0x9292: "SubsecTimeDigitized", - 0x9400: "AmbientTemperature", - 0x9401: "Humidity", - 0x9402: "Pressure", - 0x9403: "WaterDepth", - 0x9404: "Acceleration", - 0x9405: "CameraElevationAngle", - 0x9C9B: "XPTitle", - 0x9C9C: "XPComment", - 0x9C9D: "XPAuthor", - 0x9C9E: "XPKeywords", - 0x9C9F: "XPSubject", - 0xA000: "FlashPixVersion", - 0xA001: "ColorSpace", - 0xA002: "ExifImageWidth", - 0xA003: "ExifImageHeight", - 0xA004: "RelatedSoundFile", - 0xA005: "ExifInteroperabilityOffset", - 0xA20B: "FlashEnergy", - 0xA20C: "SpatialFrequencyResponse", - 0xA20E: "FocalPlaneXResolution", - 0xA20F: "FocalPlaneYResolution", - 0xA210: "FocalPlaneResolutionUnit", - 0xA214: "SubjectLocation", - 0xA215: "ExposureIndex", - 0xA217: "SensingMethod", - 0xA300: "FileSource", - 0xA301: "SceneType", - 0xA302: "CFAPattern", - 0xA401: "CustomRendered", - 0xA402: "ExposureMode", - 0xA403: "WhiteBalance", - 0xA404: "DigitalZoomRatio", - 0xA405: "FocalLengthIn35mmFilm", - 0xA406: "SceneCaptureType", - 0xA407: "GainControl", - 0xA408: "Contrast", - 0xA409: "Saturation", - 0xA40A: "Sharpness", - 0xA40B: "DeviceSettingDescription", - 0xA40C: "SubjectDistanceRange", - 0xA420: "ImageUniqueID", - 0xA430: "CameraOwnerName", - 0xA431: "BodySerialNumber", - 0xA432: "LensSpecification", - 0xA433: "LensMake", - 0xA434: "LensModel", - 0xA435: "LensSerialNumber", - 0xA500: "Gamma", - 0xC4A5: "PrintImageMatching", - 0xC612: "DNGVersion", - 0xC613: "DNGBackwardVersion", - 0xC614: "UniqueCameraModel", - 0xC615: "LocalizedCameraModel", - 0xC616: "CFAPlaneColor", - 0xC617: "CFALayout", - 0xC618: "LinearizationTable", - 0xC619: "BlackLevelRepeatDim", - 0xC61A: "BlackLevel", - 0xC61B: "BlackLevelDeltaH", - 0xC61C: "BlackLevelDeltaV", - 0xC61D: "WhiteLevel", - 0xC61E: "DefaultScale", - 0xC61F: "DefaultCropOrigin", - 0xC620: "DefaultCropSize", - 0xC621: "ColorMatrix1", - 0xC622: "ColorMatrix2", - 0xC623: "CameraCalibration1", - 0xC624: "CameraCalibration2", - 0xC625: "ReductionMatrix1", - 0xC626: "ReductionMatrix2", - 0xC627: "AnalogBalance", - 0xC628: "AsShotNeutral", - 0xC629: "AsShotWhiteXY", - 0xC62A: "BaselineExposure", - 0xC62B: "BaselineNoise", - 0xC62C: "BaselineSharpness", - 0xC62D: "BayerGreenSplit", - 0xC62E: "LinearResponseLimit", - 0xC62F: "CameraSerialNumber", - 0xC630: "LensInfo", - 0xC631: "ChromaBlurRadius", - 0xC632: "AntiAliasStrength", - 0xC633: "ShadowScale", - 0xC634: "DNGPrivateData", - 0xC635: "MakerNoteSafety", - 0xC65A: "CalibrationIlluminant1", - 0xC65B: "CalibrationIlluminant2", - 0xC65C: "BestQualityScale", - 0xC65D: "RawDataUniqueID", - 0xC68B: "OriginalRawFileName", - 0xC68C: "OriginalRawFileData", - 0xC68D: "ActiveArea", - 0xC68E: "MaskedAreas", - 0xC68F: "AsShotICCProfile", - 0xC690: "AsShotPreProfileMatrix", - 0xC691: "CurrentICCProfile", - 0xC692: "CurrentPreProfileMatrix", - 0xC6BF: "ColorimetricReference", - 0xC6F3: "CameraCalibrationSignature", - 0xC6F4: "ProfileCalibrationSignature", - 0xC6F6: "AsShotProfileName", - 0xC6F7: "NoiseReductionApplied", - 0xC6F8: "ProfileName", - 0xC6F9: "ProfileHueSatMapDims", - 0xC6FA: "ProfileHueSatMapData1", - 0xC6FB: "ProfileHueSatMapData2", - 0xC6FC: "ProfileToneCurve", - 0xC6FD: "ProfileEmbedPolicy", - 0xC6FE: "ProfileCopyright", - 0xC714: "ForwardMatrix1", - 0xC715: "ForwardMatrix2", - 0xC716: "PreviewApplicationName", - 0xC717: "PreviewApplicationVersion", - 0xC718: "PreviewSettingsName", - 0xC719: "PreviewSettingsDigest", - 0xC71A: "PreviewColorSpace", - 0xC71B: "PreviewDateTime", - 0xC71C: "RawImageDigest", - 0xC71D: "OriginalRawFileDigest", - 0xC71E: "SubTileBlockSize", - 0xC71F: "RowInterleaveFactor", - 0xC725: "ProfileLookTableDims", - 0xC726: "ProfileLookTableData", - 0xC740: "OpcodeList1", - 0xC741: "OpcodeList2", - 0xC74E: "OpcodeList3", - 0xC761: "NoiseProfile", } -"""Maps EXIF tags to tag names.""" -GPSTAGS = { - 0: "GPSVersionID", - 1: "GPSLatitudeRef", - 2: "GPSLatitude", - 3: "GPSLongitudeRef", - 4: "GPSLongitude", - 5: "GPSAltitudeRef", - 6: "GPSAltitude", - 7: "GPSTimeStamp", - 8: "GPSSatellites", - 9: "GPSStatus", - 10: "GPSMeasureMode", - 11: "GPSDOP", - 12: "GPSSpeedRef", - 13: "GPSSpeed", - 14: "GPSTrackRef", - 15: "GPSTrack", - 16: "GPSImgDirectionRef", - 17: "GPSImgDirection", - 18: "GPSMapDatum", - 19: "GPSDestLatitudeRef", - 20: "GPSDestLatitude", - 21: "GPSDestLongitudeRef", - 22: "GPSDestLongitude", - 23: "GPSDestBearingRef", - 24: "GPSDestBearing", - 25: "GPSDestDistanceRef", - 26: "GPSDestDistance", - 27: "GPSProcessingMethod", - 28: "GPSAreaInformation", - 29: "GPSDateStamp", - 30: "GPSDifferential", - 31: "GPSHPositioningError", -} +class GPS(IntEnum): + GPSVersionID = 0x00 + GPSLatitudeRef = 0x01 + GPSLatitude = 0x02 + GPSLongitudeRef = 0x03 + GPSLongitude = 0x04 + GPSAltitudeRef = 0x05 + GPSAltitude = 0x06 + GPSTimeStamp = 0x07 + GPSSatellites = 0x08 + GPSStatus = 0x09 + GPSMeasureMode = 0x0A + GPSDOP = 0x0B + GPSSpeedRef = 0x0C + GPSSpeed = 0x0D + GPSTrackRef = 0x0E + GPSTrack = 0x0F + GPSImgDirectionRef = 0x10 + GPSImgDirection = 0x11 + GPSMapDatum = 0x12 + GPSDestLatitudeRef = 0x13 + GPSDestLatitude = 0x14 + GPSDestLongitudeRef = 0x15 + GPSDestLongitude = 0x16 + GPSDestBearingRef = 0x17 + GPSDestBearing = 0x18 + GPSDestDistanceRef = 0x19 + GPSDestDistance = 0x1A + GPSProcessingMethod = 0x1B + GPSAreaInformation = 0x1C + GPSDateStamp = 0x1D + GPSDifferential = 0x1E + GPSHPositioningError = 0x1F + + """Maps EXIF GPS tags to tag names.""" +GPSTAGS = {i.value: i.name for i in GPS} + + +class Interop(IntEnum): + InteropIndex = 0x0001 + InteropVersion = 0x0002 + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageHeight = 0x1002 + + +class IFD(IntEnum): + Exif = 0x8769 + GPSInfo = 0x8825 + MakerNote = 0x927C + Makernote = 0x927C # Deprecated + Interop = 0xA005 + IFD1 = -1 + + +class LightSource(IntEnum): + Unknown = 0x00 + Daylight = 0x01 + Fluorescent = 0x02 + Tungsten = 0x03 + Flash = 0x04 + Fine = 0x09 + Cloudy = 0x0A + Shade = 0x0B + DaylightFluorescent = 0x0C + DayWhiteFluorescent = 0x0D + CoolWhiteFluorescent = 0x0E + WhiteFluorescent = 0x0F + StandardLightA = 0x11 + StandardLightB = 0x12 + StandardLightC = 0x13 + D55 = 0x14 + D65 = 0x15 + D75 = 0x16 + D50 = 0x17 + ISO = 0x18 + Other = 0xFF diff --git a/dependencies/OSX/PIL/FitsImagePlugin.py b/dependencies/OSX/PIL/FitsImagePlugin.py new file mode 100644 index 0000000..6bbd264 --- /dev/null +++ b/dependencies/OSX/PIL/FitsImagePlugin.py @@ -0,0 +1,152 @@ +# +# The Python Imaging Library +# $Id$ +# +# FITS file handling +# +# Copyright (c) 1998-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import gzip +import math + +from . import Image, ImageFile + + +def _accept(prefix: bytes) -> bool: + return prefix[:6] == b"SIMPLE" + + +class FitsImageFile(ImageFile.ImageFile): + format = "FITS" + format_description = "FITS" + + def _open(self) -> None: + assert self.fp is not None + + headers: dict[bytes, bytes] = {} + header_in_progress = False + decoder_name = "" + while True: + header = self.fp.read(80) + if not header: + msg = "Truncated FITS file" + raise OSError(msg) + keyword = header[:8].strip() + if keyword in (b"SIMPLE", b"XTENSION"): + header_in_progress = True + elif headers and not header_in_progress: + # This is now a data unit + break + elif keyword == b"END": + # Seek to the end of the header unit + self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880) + if not decoder_name: + decoder_name, offset, args = self._parse_headers(headers) + + header_in_progress = False + continue + + if decoder_name: + # Keep going to read past the headers + continue + + value = header[8:].split(b"/")[0].strip() + if value.startswith(b"="): + value = value[1:].strip() + if not headers and (not _accept(keyword) or value != b"T"): + msg = "Not a FITS file" + raise SyntaxError(msg) + headers[keyword] = value + + if not decoder_name: + msg = "No image data" + raise ValueError(msg) + + offset += self.fp.tell() - 80 + self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)] + + def _get_size( + self, headers: dict[bytes, bytes], prefix: bytes + ) -> tuple[int, int] | None: + naxis = int(headers[prefix + b"NAXIS"]) + if naxis == 0: + return None + + if naxis == 1: + return 1, int(headers[prefix + b"NAXIS1"]) + else: + return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"]) + + def _parse_headers( + self, headers: dict[bytes, bytes] + ) -> tuple[str, int, tuple[str | int, ...]]: + prefix = b"" + decoder_name = "raw" + offset = 0 + if ( + headers.get(b"XTENSION") == b"'BINTABLE'" + and headers.get(b"ZIMAGE") == b"T" + and headers[b"ZCMPTYPE"] == b"'GZIP_1 '" + ): + no_prefix_size = self._get_size(headers, prefix) or (0, 0) + number_of_bits = int(headers[b"BITPIX"]) + offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8) + + prefix = b"Z" + decoder_name = "fits_gzip" + + size = self._get_size(headers, prefix) + if not size: + return "", 0, () + + self._size = size + + number_of_bits = int(headers[prefix + b"BITPIX"]) + if number_of_bits == 8: + self._mode = "L" + elif number_of_bits == 16: + self._mode = "I;16" + elif number_of_bits == 32: + self._mode = "I" + elif number_of_bits in (-32, -64): + self._mode = "F" + + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) + return decoder_name, offset, args + + +class FitsGzipDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + value = gzip.decompress(self.fd.read()) + + rows = [] + offset = 0 + number_of_bits = min(self.args[0] // 8, 4) + for y in range(self.state.ysize): + row = bytearray() + for x in range(self.state.xsize): + row += value[offset + (4 - number_of_bits) : offset + 4] + offset += 4 + rows.append(row) + self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) + return -1, 0 + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(FitsImageFile.format, FitsImageFile, _accept) +Image.register_decoder("fits_gzip", FitsGzipDecoder) + +Image.register_extensions(FitsImageFile.format, [".fit", ".fits"]) diff --git a/dependencies/OSX/PIL/FitsStubImagePlugin.py b/dependencies/OSX/PIL/FitsStubImagePlugin.py deleted file mode 100644 index c2ce865..0000000 --- a/dependencies/OSX/PIL/FitsStubImagePlugin.py +++ /dev/null @@ -1,76 +0,0 @@ -# -# The Python Imaging Library -# $Id$ -# -# FITS stub adapter -# -# Copyright (c) 1998-2003 by Fredrik Lundh -# -# See the README file for information on usage and redistribution. -# - -from . import Image, ImageFile - -_handler = None - - -def register_handler(handler): - """ - Install application-specific FITS image handler. - - :param handler: Handler object. - """ - global _handler - _handler = handler - - -# -------------------------------------------------------------------- -# Image adapter - - -def _accept(prefix): - return prefix[:6] == b"SIMPLE" - - -class FITSStubImageFile(ImageFile.StubImageFile): - - format = "FITS" - format_description = "FITS" - - def _open(self): - - offset = self.fp.tell() - - if not _accept(self.fp.read(6)): - raise SyntaxError("Not a FITS file") - - # FIXME: add more sanity checks here; mandatory header items - # include SIMPLE, BITPIX, NAXIS, etc. - - self.fp.seek(offset) - - # make something up - self.mode = "F" - self._size = 1, 1 - - loader = self._load() - if loader: - loader.open(self) - - def _load(self): - return _handler - - -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("FITS save handler not installed") - _handler.save(im, fp, filename) - - -# -------------------------------------------------------------------- -# Registry - -Image.register_open(FITSStubImageFile.format, FITSStubImageFile, _accept) -Image.register_save(FITSStubImageFile.format, _save) - -Image.register_extensions(FITSStubImageFile.format, [".fit", ".fits"]) diff --git a/dependencies/OSX/PIL/FliImagePlugin.py b/dependencies/OSX/PIL/FliImagePlugin.py index 3c88d53..b534b30 100644 --- a/dependencies/OSX/PIL/FliImagePlugin.py +++ b/dependencies/OSX/PIL/FliImagePlugin.py @@ -14,10 +14,11 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import os from . import Image, ImageFile, ImagePalette -from ._binary import i8 from ._binary import i16le as i16 from ._binary import i32le as i32 from ._binary import o8 @@ -26,8 +27,12 @@ # decoder -def _accept(prefix): - return len(prefix) >= 6 and i16(prefix[4:6]) in [0xAF11, 0xAF12] +def _accept(prefix: bytes) -> bool: + return ( + len(prefix) >= 6 + and i16(prefix, 4) in [0xAF11, 0xAF12] + and i16(prefix, 14) in [0, 3] # flags + ) ## @@ -36,33 +41,28 @@ def _accept(prefix): class FliImageFile(ImageFile.ImageFile): - format = "FLI" format_description = "Autodesk FLI/FLC Animation" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # HEAD s = self.fp.read(128) - if not ( - _accept(s) - and i16(s[14:16]) in [0, 3] # flags - and s[20:22] == b"\x00\x00" # reserved - ): - raise SyntaxError("not an FLI/FLC file") + if not (_accept(s) and s[20:22] == b"\x00\x00"): + msg = "not an FLI/FLC file" + raise SyntaxError(msg) # frames - self.n_frames = i16(s[6:8]) + self.n_frames = i16(s, 6) self.is_animated = self.n_frames > 1 # image characteristics - self.mode = "P" - self._size = i16(s[8:10]), i16(s[10:12]) + self._mode = "P" + self._size = i16(s, 8), i16(s, 10) # animation speed - duration = i32(s[16:20]) - magic = i16(s[4:6]) + duration = i32(s, 16) + magic = i16(s, 4) if magic == 0xAF11: duration = (duration * 1000) // 70 self.info["duration"] = duration @@ -74,47 +74,57 @@ def _open(self): self.__offset = 128 - if i16(s[4:6]) == 0xF100: + if i16(s, 4) == 0xF100: # prefix chunk; ignore it self.__offset = self.__offset + i32(s) + self.fp.seek(self.__offset) s = self.fp.read(16) - if i16(s[4:6]) == 0xF1FA: + if i16(s, 4) == 0xF1FA: # look for palette chunk - s = self.fp.read(6) - if i16(s[4:6]) == 11: - self._palette(palette, 2) - elif i16(s[4:6]) == 4: - self._palette(palette, 0) - - palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] - self.palette = ImagePalette.raw("RGB", b"".join(palette)) + number_of_subchunks = i16(s, 6) + chunk_size: int | None = None + for _ in range(number_of_subchunks): + if chunk_size is not None: + self.fp.seek(chunk_size - 6, os.SEEK_CUR) + s = self.fp.read(6) + chunk_type = i16(s, 4) + if chunk_type in (4, 11): + self._palette(palette, 2 if chunk_type == 11 else 0) + break + chunk_size = i32(s) + if not chunk_size: + break + + self.palette = ImagePalette.raw( + "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette) + ) # set things up to decode first frame self.__frame = -1 - self.__fp = self.fp + self._fp = self.fp self.__rewind = self.fp.tell() self.seek(0) - def _palette(self, palette, shift): + def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None: # load palette i = 0 for e in range(i16(self.fp.read(2))): s = self.fp.read(2) - i = i + i8(s[0]) - n = i8(s[1]) + i = i + s[0] + n = s[1] if n == 0: n = 256 s = self.fp.read(n * 3) for n in range(0, len(s), 3): - r = i8(s[n]) << shift - g = i8(s[n + 1]) << shift - b = i8(s[n + 2]) << shift + r = s[n] << shift + g = s[n + 1] << shift + b = s[n + 2] << shift palette[i] = (r, g, b) i += 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: @@ -123,46 +133,39 @@ def seek(self, frame): for f in range(self.__frame + 1, frame + 1): self._seek(f) - def _seek(self, frame): + def _seek(self, frame: int) -> None: if frame == 0: self.__frame = -1 - self.__fp.seek(self.__rewind) + self._fp.seek(self.__rewind) self.__offset = 128 else: # ensure that the previous frame was loaded self.load() if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) self.__frame = frame # move to next frame - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self.__offset) s = self.fp.read(4) if not s: - raise EOFError + msg = "missing frame size" + raise EOFError(msg) framesize = i32(s) self.decodermaxblock = framesize - self.tile = [("fli", (0, 0) + self.size, self.__offset, None)] + self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)] self.__offset += framesize - def tell(self): + def tell(self) -> int: return self.__frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # registry diff --git a/dependencies/OSX/PIL/FontFile.py b/dependencies/OSX/PIL/FontFile.py index c5fc80b..1e0c1c1 100644 --- a/dependencies/OSX/PIL/FontFile.py +++ b/dependencies/OSX/PIL/FontFile.py @@ -13,16 +13,19 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os +from typing import BinaryIO from . import Image, _binary WIDTH = 800 -def puti16(fp, values): +def puti16( + fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int] +) -> None: """Write network order (big-endian) 16-bit sequence""" for v in values: if v < 0: @@ -33,17 +36,32 @@ def puti16(fp, values): class FontFile: """Base class for raster font file handlers.""" - bitmap = None - - def __init__(self): - - self.info = {} - self.glyph = [None] * 256 - - def __getitem__(self, ix): + bitmap: Image.Image | None = None + + def __init__(self) -> None: + self.info: dict[bytes, bytes | int] = {} + self.glyph: list[ + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ] = [None] * 256 + + def __getitem__(self, ix: int) -> ( + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ): return self.glyph[ix] - def compile(self): + def compile(self) -> None: """Create metrics and bitmap""" if self.bitmap: @@ -52,7 +70,7 @@ def compile(self): # create bitmap large enough to hold all data h = w = maxwidth = 0 lines = 1 - for glyph in self: + for glyph in self.glyph: if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) @@ -66,20 +84,22 @@ def compile(self): ysize = lines * h if xsize == 0 and ysize == 0: - return "" + return self.ysize = h # paste glyphs into bitmap self.bitmap = Image.new("1", (xsize, ysize)) - self.metrics = [None] * 256 + self.metrics: list[ + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]] + | None + ] = [None] * 256 x = y = 0 for i in range(256): glyph = self[i] if glyph: d, dst, src, im = glyph xx = src[2] - src[0] - # yy = src[3] - src[1] x0, y0 = x, y x = x + xx if x > WIDTH: @@ -90,12 +110,15 @@ def compile(self): self.bitmap.paste(im.crop(src), s) self.metrics[i] = d, dst, s - def save(self, filename): + def save(self, filename: str) -> None: """Save font""" self.compile() # font data + if not self.bitmap: + msg = "No bitmap created" + raise ValueError(msg) self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG") # font metrics @@ -106,6 +129,6 @@ def save(self, filename): for id in range(256): m = self.metrics[id] if not m: - puti16(fp, [0] * 10) + puti16(fp, (0,) * 10) else: puti16(fp, m[0] + m[1] + m[2]) diff --git a/dependencies/OSX/PIL/FpxImagePlugin.py b/dependencies/OSX/PIL/FpxImagePlugin.py index 14070ee..4cfcb06 100644 --- a/dependencies/OSX/PIL/FpxImagePlugin.py +++ b/dependencies/OSX/PIL/FpxImagePlugin.py @@ -14,16 +14,17 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import olefile from . import Image, ImageFile -from ._binary import i8 from ._binary import i32le as i32 # we map from colour field tuples to (mode, rawmode) descriptors MODES = { # opacity - (0x00007FFE): ("A", "L"), + (0x00007FFE,): ("A", "L"), # monochrome (0x00010000,): ("L", "L"), (0x00018000, 0x00017FFE): ("RGBA", "LA"), @@ -40,7 +41,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == olefile.MAGIC @@ -49,11 +50,10 @@ def _accept(prefix): class FpxImageFile(ImageFile.ImageFile): - format = "FPX" format_description = "FlashPix" - def _open(self): + def _open(self) -> None: # # read the OLE directory and see if this is a likely # to be a FlashPix file @@ -61,14 +61,17 @@ def _open(self): try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an FPX file; invalid OLE file") from e + msg = "not an FPX file; invalid OLE file" + raise SyntaxError(msg) from e - if self.ole.root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": - raise SyntaxError("not an FPX file; bad root CLSID") + root = self.ole.root + if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": + msg = "not an FPX file; bad root CLSID" + raise SyntaxError(msg) self._open_index(1) - def _open_index(self, index=1): + def _open_index(self, index: int = 1) -> None: # # get the Image Contents Property Set @@ -78,12 +81,14 @@ def _open_index(self, index=1): # size (highest resolution) + assert isinstance(prop[0x1000002], int) + assert isinstance(prop[0x1000003], int) self._size = prop[0x1000002], prop[0x1000003] size = max(self.size) i = 1 while size > 64: - size = size / 2 + size = size // 2 i += 1 self.maxid = i - 1 @@ -97,15 +102,14 @@ def _open_index(self, index=1): s = prop[0x2000002 | id] - colors = [] - bands = i32(s, 4) - if bands > 4: - raise OSError("Invalid number of bands") - for i in range(bands): - # note: for now, we ignore the "uncalibrated" flag - colors.append(i32(s, 8 + i * 4) & 0x7FFFFFFF) + if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4: + msg = "Invalid number of bands" + raise OSError(msg) - self.mode, self.rawmode = MODES[tuple(colors)] + # note: for now, we ignore the "uncalibrated" flag + colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands)) + + self._mode, self.rawmode = MODES[colors] # load JPEG tables, if any self.jpeg = {} @@ -116,7 +120,7 @@ def _open_index(self, index=1): self._open_subimage(1, self.maxid) - def _open_subimage(self, index=1, subimage=0): + def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: # # setup tile descriptors for a given subimage @@ -142,7 +146,8 @@ def _open_subimage(self, index=1, subimage=0): length = i32(s, 32) if size != self.size: - raise OSError("subimage mismatch") + msg = "subimage mismatch" + raise OSError(msg) # get tile descriptors fp.seek(28 + offset) @@ -154,35 +159,35 @@ def _open_subimage(self, index=1, subimage=0): self.tile = [] for i in range(0, len(s), length): + x1 = min(xsize, x + xtile) + y1 = min(ysize, y + ytile) compression = i32(s, i + 8) if compression == 0: self.tile.append( - ( + ImageFile._Tile( "raw", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, - (self.rawmode), + self.rawmode, ) ) elif compression == 1: - # FIXME: the fill decoder is not implemented self.tile.append( - ( + ImageFile._Tile( "fill", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, (self.rawmode, s[12:16]), ) ) elif compression == 2: - - internal_color_conversion = i8(s[14]) - jpeg_tables = i8(s[15]) + internal_color_conversion = s[14] + jpeg_tables = s[15] rawmode = self.rawmode if internal_color_conversion: @@ -200,9 +205,9 @@ def _open_subimage(self, index=1, subimage=0): jpegmode = rawmode self.tile.append( - ( + ImageFile._Tile( "jpeg", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, (rawmode, jpegmode), ) @@ -215,7 +220,8 @@ def _open_subimage(self, index=1, subimage=0): self.tile_prefix = self.jpeg[jpeg_tables] else: - raise OSError("unknown/invalid compression") + msg = "unknown/invalid compression" + raise OSError(msg) x = x + xtile if x >= xsize: @@ -224,15 +230,23 @@ def _open_subimage(self, index=1, subimage=0): break # isn't really required self.stream = stream + self._fp = self.fp self.fp = None - def load(self): - + def load(self) -> Image.core.PixelAccess | None: if not self.fp: self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) return ImageFile.ImageFile.load(self) + def close(self) -> None: + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.ole.close() + super().__exit__() + # # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/FtexImagePlugin.py b/dependencies/OSX/PIL/FtexImagePlugin.py index 9006612..0516b76 100644 --- a/dependencies/OSX/PIL/FtexImagePlugin.py +++ b/dependencies/OSX/PIL/FtexImagePlugin.py @@ -51,27 +51,35 @@ Note: All data is stored in little-Endian (Intel) byte order. """ +from __future__ import annotations + import struct +from enum import IntEnum from io import BytesIO from . import Image, ImageFile MAGIC = b"FTEX" -FORMAT_DXT1 = 0 -FORMAT_UNCOMPRESSED = 1 + + +class Format(IntEnum): + DXT1 = 0 + UNCOMPRESSED = 1 class FtexImageFile(ImageFile.ImageFile): format = "FTEX" format_description = "Texture File Format (IW2:EOC)" - def _open(self): - struct.unpack(" None: + if not _accept(self.fp.read(4)): + msg = "not an FTEX file" + raise SyntaxError(msg) struct.unpack(" None: pass -def _validate(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == MAGIC -Image.register_open(FtexImageFile.format, FtexImageFile, _validate) +Image.register_open(FtexImageFile.format, FtexImageFile, _accept) Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"]) diff --git a/dependencies/OSX/PIL/GbrImagePlugin.py b/dependencies/OSX/PIL/GbrImagePlugin.py index 5e447b4..f319d7e 100644 --- a/dependencies/OSX/PIL/GbrImagePlugin.py +++ b/dependencies/OSX/PIL/GbrImagePlugin.py @@ -23,13 +23,14 @@ # Version 2 files are saved by GIMP v2.8 (at least) # Version 3 files have a format specifier of 18 for 16bit floats in # the color depth field. This is currently unsupported by Pillow. +from __future__ import annotations from . import Image, ImageFile from ._binary import i32be as i32 -def _accept(prefix): - return len(prefix) >= 8 and i32(prefix[:4]) >= 20 and i32(prefix[4:8]) in (1, 2) +def _accept(prefix: bytes) -> bool: + return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2) ## @@ -37,25 +38,28 @@ def _accept(prefix): class GbrImageFile(ImageFile.ImageFile): - format = "GBR" format_description = "GIMP brush file" - def _open(self): + def _open(self) -> None: header_size = i32(self.fp.read(4)) - version = i32(self.fp.read(4)) if header_size < 20: - raise SyntaxError("not a GIMP brush") + msg = "not a GIMP brush" + raise SyntaxError(msg) + version = i32(self.fp.read(4)) if version not in (1, 2): - raise SyntaxError(f"Unsupported GIMP brush version: {version}") + msg = f"Unsupported GIMP brush version: {version}" + raise SyntaxError(msg) width = i32(self.fp.read(4)) height = i32(self.fp.read(4)) color_depth = i32(self.fp.read(4)) if width <= 0 or height <= 0: - raise SyntaxError("not a GIMP brush") + msg = "not a GIMP brush" + raise SyntaxError(msg) if color_depth not in (1, 4): - raise SyntaxError(f"Unsupported GIMP brush color depth: {color_depth}") + msg = f"Unsupported GIMP brush color depth: {color_depth}" + raise SyntaxError(msg) if version == 1: comment_length = header_size - 20 @@ -63,15 +67,16 @@ def _open(self): comment_length = header_size - 28 magic_number = self.fp.read(4) if magic_number != b"GIMP": - raise SyntaxError("not a GIMP brush, bad magic number") + msg = "not a GIMP brush, bad magic number" + raise SyntaxError(msg) self.info["spacing"] = i32(self.fp.read(4)) comment = self.fp.read(comment_length)[:-1] if color_depth == 1: - self.mode = "L" + self._mode = "L" else: - self.mode = "RGBA" + self._mode = "RGBA" self._size = width, height @@ -83,13 +88,11 @@ def _open(self): # Data is an uncompressed block of w * h * bytes/pixel self._data_size = width * height * color_depth - def load(self): - if self.im: - # Already loaded - return - - self.im = Image.core.new(self.mode, self.size) - self.frombytes(self.fp.read(self._data_size)) + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self._data_size)) + return Image.Image.load(self) # diff --git a/dependencies/OSX/PIL/GdImageFile.py b/dependencies/OSX/PIL/GdImageFile.py index 8561f7b..fc4801e 100644 --- a/dependencies/OSX/PIL/GdImageFile.py +++ b/dependencies/OSX/PIL/GdImageFile.py @@ -25,12 +25,14 @@ class is not registered for use with :py:func:`PIL.Image.open()`. To open a implementation is provided for convenience and demonstrational purposes only. """ +from __future__ import annotations +from typing import IO from . import ImageFile, ImagePalette, UnidentifiedImageError -from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._typing import StrOrBytesPath class GdImageFile(ImageFile.ImageFile): @@ -44,48 +46,57 @@ class GdImageFile(ImageFile.ImageFile): format = "GD" format_description = "GD uncompressed images" - def _open(self): - + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(1037) - if not i16(s[:2]) in [65534, 65535]: - raise SyntaxError("Not a valid GD 2.x .gd file") + if i16(s) not in [65534, 65535]: + msg = "Not a valid GD 2.x .gd file" + raise SyntaxError(msg) - self.mode = "L" # FIXME: "P" - self._size = i16(s[2:4]), i16(s[4:6]) + self._mode = "L" # FIXME: "P" + self._size = i16(s, 2), i16(s, 4) - trueColor = i8(s[6]) - trueColorOffset = 2 if trueColor else 0 + true_color = s[6] + true_color_offset = 2 if true_color else 0 # transparency index - tindex = i32(s[7 + trueColorOffset : 7 + trueColorOffset + 4]) + tindex = i32(s, 7 + true_color_offset) if tindex < 256: self.info["transparency"] = tindex self.palette = ImagePalette.raw( - "XBGR", s[7 + trueColorOffset + 4 : 7 + trueColorOffset + 4 + 256 * 4] + "XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4] ) self.tile = [ - ("raw", (0, 0) + self.size, 7 + trueColorOffset + 4 + 256 * 4, ("L", 0, 1)) + ImageFile._Tile( + "raw", + (0, 0) + self.size, + 7 + true_color_offset + 4 + 256 * 4, + "L", + ) ] -def open(fp, mode="r"): +def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile: """ Load texture from a GD image file. - :param filename: GD file name, or an opened file handle. + :param fp: GD file name, or an opened file handle. :param mode: Optional mode. In this version, if the mode argument is given, it must be "r". :returns: An image instance. :raises OSError: If the image could not be read. """ if mode != "r": - raise ValueError("bad mode") + msg = "bad mode" + raise ValueError(msg) try: return GdImageFile(fp) except SyntaxError as e: - raise UnidentifiedImageError("cannot identify this image file") from e + msg = "cannot identify this image file" + raise UnidentifiedImageError(msg) from e diff --git a/dependencies/OSX/PIL/GifImagePlugin.py b/dependencies/OSX/PIL/GifImagePlugin.py index 4ca5a69..47022d5 100644 --- a/dependencies/OSX/PIL/GifImagePlugin.py +++ b/dependencies/OSX/PIL/GifImagePlugin.py @@ -23,23 +23,50 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import math import os import subprocess - -from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence -from ._binary import i8 +from enum import IntEnum +from functools import cached_property +from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union + +from . import ( + Image, + ImageChops, + ImageFile, + ImageMath, + ImageOps, + ImagePalette, + ImageSequence, +) from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + from ._typing import Buffer + + +class LoadingStrategy(IntEnum): + """.. versionadded:: 9.1.0""" + + RGB_AFTER_FIRST = 0 + RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1 + RGB_ALWAYS = 2 + + +#: .. versionadded:: 9.1.0 +LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST + # -------------------------------------------------------------------- # Identify/read GIF files -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] in [b"GIF87a", b"GIF89a"] @@ -49,84 +76,85 @@ def _accept(prefix): class GifImageFile(ImageFile.ImageFile): - format = "GIF" format_description = "Compuserve GIF" _close_exclusive_fp_after_loading = False global_palette = None - def data(self): + def data(self) -> bytes | None: s = self.fp.read(1) - if s and i8(s): - return self.fp.read(i8(s)) + if s and s[0]: + return self.fp.read(s[0]) return None - def _open(self): + def _is_palette_needed(self, p: bytes) -> bool: + for i in range(0, len(p), 3): + if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): + return True + return False + def _open(self) -> None: # Screen s = self.fp.read(13) if not _accept(s): - raise SyntaxError("not a GIF file") + msg = "not a GIF file" + raise SyntaxError(msg) self.info["version"] = s[:6] - self._size = i16(s[6:]), i16(s[8:]) - self.tile = [] - flags = i8(s[10]) + self._size = i16(s, 6), i16(s, 8) + flags = s[10] bits = (flags & 7) + 1 if flags & 128: # get global palette - self.info["background"] = i8(s[11]) + self.info["background"] = s[11] # check if palette contains colour indices p = self.fp.read(3 << bits) - for i in range(0, len(p), 3): - if not (i // 3 == i8(p[i]) == i8(p[i + 1]) == i8(p[i + 2])): - p = ImagePalette.raw("RGB", p) - self.global_palette = self.palette = p - break + if self._is_palette_needed(p): + p = ImagePalette.raw("RGB", p) + self.global_palette = self.palette = p - self.__fp = self.fp # FIXME: hack + self._fp = self.fp # FIXME: hack self.__rewind = self.fp.tell() - self._n_frames = None - self._is_animated = None + self._n_frames: int | None = None self._seek(0) # get ready to read first frame @property - def n_frames(self): + def n_frames(self) -> int: if self._n_frames is None: current = self.tell() try: while True: - self.seek(self.tell() + 1) + self._seek(self.tell() + 1, False) except EOFError: self._n_frames = self.tell() + 1 self.seek(current) return self._n_frames - @property - def is_animated(self): - if self._is_animated is None: - if self._n_frames is not None: - self._is_animated = self._n_frames != 1 - else: - current = self.tell() + @cached_property + def is_animated(self) -> bool: + if self._n_frames is not None: + return self._n_frames != 1 - try: - self.seek(1) - self._is_animated = True - except EOFError: - self._is_animated = False + current = self.tell() + if current: + return True - self.seek(current) - return self._is_animated + try: + self._seek(1, False) + is_animated = True + except EOFError: + is_animated = False - def seek(self, frame): + self.seek(current) + return is_animated + + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: - if frame != 0: - self.im = None + self._im = None self._seek(0) last_frame = self.__frame @@ -135,31 +163,29 @@ def seek(self, frame): self._seek(f) except EOFError as e: self.seek(last_frame) - raise EOFError("no more images in GIF file") from e - - def _seek(self, frame): + msg = "no more images in GIF file" + raise EOFError(msg) from e + def _seek(self, frame: int, update_image: bool = True) -> None: if frame == 0: # rewind self.__offset = 0 - self.dispose = None - self.dispose_extent = [0, 0, 0, 0] # x0, y0, x1, y1 + self.dispose: _imaging.ImagingCore | None = None self.__frame = -1 - self.__fp.seek(self.__rewind) - self._prev_im = None + self._fp.seek(self.__rewind) self.disposal_method = 0 + if "comment" in self.info: + del self.info["comment"] else: # ensure that the previous frame was loaded - if not self.im: + if self.tile and update_image: self.load() if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") - self.__frame = frame - - self.tile = [] + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) - self.fp = self.__fp + self.fp = self._fp if self.__offset: # backup to last frame self.fp.seek(self.__offset) @@ -167,17 +193,20 @@ def _seek(self, frame): pass self.__offset = 0 - if self.dispose: - self.im.paste(self.dispose, self.dispose_extent) - - from copy import copy + s = self.fp.read(1) + if not s or s == b";": + msg = "no more images in GIF file" + raise EOFError(msg) - self.palette = copy(self.global_palette) + palette: ImagePalette.ImagePalette | Literal[False] | None = None - info = {} + info: dict[str, Any] = {} + frame_transparency = None + interlace = None + frame_dispose_extent = None while True: - - s = self.fp.read(1) + if not s: + s = self.fp.read(1) if not s or s == b";": break @@ -187,14 +216,14 @@ def _seek(self, frame): # s = self.fp.read(1) block = self.data() - if i8(s) == 249: + if s[0] == 249 and block is not None: # # graphic control extension # - flags = i8(block[0]) + flags = block[0] if flags & 1: - info["transparency"] = i8(block[3]) - info["duration"] = i16(block[1:3]) * 10 + frame_transparency = block[3] + info["duration"] = i16(block, 1) * 10 # disposal method - find the value of bits 4 - 6 dispose_bits = 0b00011100 & flags @@ -205,26 +234,33 @@ def _seek(self, frame): # correct, but it seems to prevent the last # frame from looking odd for some animations self.disposal_method = dispose_bits - elif i8(s) == 254: + elif s[0] == 254: # # comment extension # + comment = b"" + + # Read this comment block while block: - if "comment" in info: - info["comment"] += block - else: - info["comment"] = block + comment += block block = self.data() + + if "comment" in info: + # If multiple comment blocks in frame, separate with \n + info["comment"] += b"\n" + comment + else: + info["comment"] = comment + s = None continue - elif i8(s) == 255: + elif s[0] == 255 and frame == 0 and block is not None: # # application extension # info["extension"] = block, self.fp.tell() if block[:11] == b"NETSCAPE2.0": block = self.data() - if len(block) >= 3 and i8(block[0]) == 1: - info["loop"] = i16(block[1:3]) + if block and len(block) >= 3 and block[0] == 1: + self.info["loop"] = i16(block, 1) while self.data(): pass @@ -235,88 +271,225 @@ def _seek(self, frame): s = self.fp.read(9) # extent - x0, y0 = i16(s[0:]), i16(s[2:]) - x1, y1 = x0 + i16(s[4:]), y0 + i16(s[6:]) - if x1 > self.size[0] or y1 > self.size[1]: + x0, y0 = i16(s, 0), i16(s, 2) + x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) + if (x1 > self.size[0] or y1 > self.size[1]) and update_image: self._size = max(x1, self.size[0]), max(y1, self.size[1]) - self.dispose_extent = x0, y0, x1, y1 - flags = i8(s[8]) + Image._decompression_bomb_check(self._size) + frame_dispose_extent = x0, y0, x1, y1 + flags = s[8] interlace = (flags & 64) != 0 if flags & 128: bits = (flags & 7) + 1 - self.palette = ImagePalette.raw("RGB", self.fp.read(3 << bits)) + p = self.fp.read(3 << bits) + if self._is_palette_needed(p): + palette = ImagePalette.raw("RGB", p) + else: + palette = False # image data - bits = i8(self.fp.read(1)) + bits = self.fp.read(1)[0] self.__offset = self.fp.tell() - self.tile = [ - ("gif", (x0, y0, x1, y1), self.__offset, (bits, interlace)) - ] break + s = None + + if interlace is None: + msg = "image not found in GIF frame" + raise EOFError(msg) + + self.__frame = frame + if not update_image: + return + + self.tile = [] + + if self.dispose: + self.im.paste(self.dispose, self.dispose_extent) + self._frame_palette = palette if palette is not None else self.global_palette + self._frame_transparency = frame_transparency + if frame == 0: + if self._frame_palette: + if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + self._mode = "RGBA" if frame_transparency is not None else "RGB" + else: + self._mode = "P" else: - pass - # raise OSError, "illegal GIF tag `%x`" % i8(s) + self._mode = "L" - try: - if self.disposal_method < 2: - # do not dispose or none specified - self.dispose = None - elif self.disposal_method == 2: - # replace with background colour - Image._decompression_bomb_check(self.size) - self.dispose = Image.core.fill("P", self.size, self.info["background"]) + if palette: + self.palette = palette + elif self.global_palette: + from copy import copy + + self.palette = copy(self.global_palette) else: - # replace with previous contents - if self.im: - self.dispose = self.im.copy() - - # only dispose the extent in this frame - if self.dispose: - self.dispose = self._crop(self.dispose, self.dispose_extent) - except (AttributeError, KeyError): - pass + self.palette = None + else: + if self.mode == "P": + if ( + LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY + or palette + ): + if "transparency" in self.info: + self.im.putpalettealpha(self.info["transparency"], 0) + self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG) + self._mode = "RGBA" + del self.info["transparency"] + else: + self._mode = "RGB" + self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) + + def _rgb(color: int) -> tuple[int, int, int]: + if self._frame_palette: + if color * 3 + 3 > len(self._frame_palette.palette): + color = 0 + return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) + else: + return (color, color, color) - if not self.tile: - # self.__fp = None - raise EOFError + self.dispose = None + self.dispose_extent = frame_dispose_extent + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour + + # only dispose the extent in this frame + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + + # by convention, attempt to use transparency first + dispose_mode = "P" + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) + self.dispose = Image.core.fill(dispose_mode, dispose_size, color) + else: + # replace with previous contents + if self._im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass - for k in ["transparency", "duration", "comment", "extension", "loop"]: + if interlace is not None: + transparency = -1 + if frame_transparency is not None: + if frame == 0: + if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS: + self.info["transparency"] = frame_transparency + elif self.mode not in ("RGB", "RGBA"): + transparency = frame_transparency + self.tile = [ + ImageFile._Tile( + "gif", + (x0, y0, x1, y1), + self.__offset, + (bits, interlace, transparency), + ) + ] + + if info.get("comment"): + self.info["comment"] = info["comment"] + for k in ["duration", "extension"]: if k in info: self.info[k] = info[k] elif k in self.info: del self.info[k] - self.mode = "L" - if self.palette: - self.mode = "P" - - def tell(self): - return self.__frame + def load_prepare(self) -> None: + temp_mode = "P" if self._frame_palette else "L" + self._prev_im = None + if self.__frame == 0: + if self._frame_transparency is not None: + self.im = Image.core.fill( + temp_mode, self.size, self._frame_transparency + ) + elif self.mode in ("RGB", "RGBA"): + self._prev_im = self.im + if self._frame_palette: + self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) + self.im.putpalette("RGB", *self._frame_palette.getdata()) + else: + self._im = None + if not self._prev_im and self._im is not None and self.size != self.im.size: + expanded_im = Image.core.fill(self.im.mode, self.size) + if self._frame_palette: + expanded_im.putpalette("RGB", *self._frame_palette.getdata()) + expanded_im.paste(self.im, (0, 0) + self.im.size) + + self.im = expanded_im + self._mode = temp_mode + self._frame_palette = None + + super().load_prepare() + + def load_end(self) -> None: + if self.__frame == 0: + if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + self._mode = "RGBA" + else: + self._mode = "RGB" + self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) + return + if not self._prev_im: + return + if self.size != self._prev_im.size: + if self._frame_transparency is not None: + expanded_im = Image.core.fill("RGBA", self.size) + else: + expanded_im = Image.core.fill("P", self.size) + expanded_im.putpalette("RGB", "RGB", self.im.getpalette()) + expanded_im = expanded_im.convert("RGB") + expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size) + + self._prev_im = expanded_im + assert self._prev_im is not None + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + frame_im = self.im.convert("RGBA") + else: + frame_im = self.im.convert("RGB") - def load_end(self): - ImageFile.ImageFile.load_end(self) + assert self.dispose_extent is not None + frame_im = self._crop(frame_im, self.dispose_extent) - # if the disposal method is 'do not dispose', transparent - # pixels should show the content of the previous frame - if self._prev_im and self.disposal_method == 1: - # we do this by pasting the updated area onto the previous - # frame which we then use as the current image content - updated = self._crop(self.im, self.dispose_extent) - self._prev_im.paste(updated, self.dispose_extent, updated.convert("RGBA")) - self.im = self._prev_im - self._prev_im = self.im.copy() + self.im = self._prev_im + self._mode = self.im.mode + if frame_im.mode == "RGBA": + self.im.paste(frame_im, self.dispose_extent, frame_im) + else: + self.im.paste(frame_im, self.dispose_extent) - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None + def tell(self) -> int: + return self.__frame # -------------------------------------------------------------------- @@ -326,7 +499,7 @@ def _close__fp(self): RAWMODE = {"1": "L", "L": "L", "P": "P"} -def _normalize_mode(im, initial_call=False): +def _normalize_mode(im: Image.Image) -> Image.Image: """ Takes an image (or frame), returns an image in a mode that is appropriate for saving in a Gif. @@ -334,29 +507,30 @@ def _normalize_mode(im, initial_call=False): It may return the original image, or it may return an image converted to palette or 'L' mode. - UNDONE: What is the point of mucking with the initial call palette, for - an image that shouldn't have a palette, or it would be a mode 'P' and - get returned in the RAWMODE clause. - :param im: Image object - :param initial_call: Default false, set to true for a single frame. :returns: Image object """ if im.mode in RAWMODE: im.load() return im if Image.getmodebase(im.mode) == "RGB": - if initial_call: - palette_size = 256 - if im.palette: - palette_size = len(im.palette.getdata()[1]) // 3 - return im.convert("P", palette=Image.ADAPTIVE, colors=palette_size) - else: - return im.convert("P") + im = im.convert("P", palette=Image.Palette.ADAPTIVE) + assert im.palette is not None + if im.palette.mode == "RGBA": + for rgba in im.palette.colors: + if rgba[3] == 0: + im.info["transparency"] = im.palette.colors[rgba] + break + return im return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -374,36 +548,66 @@ def _normalize_palette(im, palette, info): if isinstance(palette, (bytes, bytearray, list)): source_palette = bytearray(palette[:768]) if isinstance(palette, ImagePalette.ImagePalette): - source_palette = bytearray( - itertools.chain.from_iterable( - zip( - palette.palette[:256], - palette.palette[256:512], - palette.palette[512:768], - ) - ) - ) + source_palette = bytearray(palette.palette) if im.mode == "P": if not source_palette: - source_palette = im.im.getpalette("RGB")[:768] + im_palette = im.getpalette(None) + assert im_palette is not None + source_palette = bytearray(im_palette) else: # L-mode if not source_palette: source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + assert source_palette is not None - used_palette_colors = _get_optimize(im, info) - if used_palette_colors is not None: - return im.remap_palette(used_palette_colors, source_palette) + if palette: + used_palette_colors: list[int | None] = [] + assert im.palette is not None + for i in range(0, len(source_palette), 3): + source_color = tuple(source_palette[i : i + 3]) + index = im.palette.colors.get(source_color) + if index in used_palette_colors: + index = None + used_palette_colors.append(index) + for i, index in enumerate(used_palette_colors): + if index is None: + for j in range(len(used_palette_colors)): + if j not in used_palette_colors: + used_palette_colors[i] = j + break + dest_map: list[int] = [] + for index in used_palette_colors: + assert index is not None + dest_map.append(index) + im = im.remap_palette(dest_map) + else: + optimized_palette_colors = _get_optimize(im, info) + if optimized_palette_colors is not None: + im = im.remap_palette(optimized_palette_colors, source_palette) + if "transparency" in info: + try: + info["transparency"] = optimized_palette_colors.index( + info["transparency"] + ) + except ValueError: + del info["transparency"] + return im + assert im.palette is not None im.palette.palette = source_palette return im -def _write_single_frame(im, fp, palette): - im_out = _normalize_mode(im, True) +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: _Palette | None, +) -> None: + im_out = _normalize_mode(im) for k, v in im_out.info.items(): - im.encoderinfo.setdefault(k, v) + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) im_out = _normalize_palette(im_out, palette, im.encoderinfo) for s in _get_global_header(im_out, im.encoderinfo): @@ -416,17 +620,40 @@ def _write_single_frame(im, fp, palette): _write_local_header(fp, im, (0, 0), flags) im_out.encoderconfig = (8, get_interlace(im)) - ImageFile._save(im_out, fp, [("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]) + ImageFile._save( + im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])] + ) fp.write(b"\0") # end of image data -def _write_multiple_frames(im, fp, palette): +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: + palette_bytes = [ + bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame) + ] + if palette_bytes[0] != palette_bytes[1]: + im_frame = im_frame.convert("RGBA") + base_im = base_im.convert("RGBA") + delta = ImageChops.subtract_modulo(im_frame, base_im) + return delta, delta.getbbox(alpha_only=False) + + +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + - duration = im.encoderinfo.get("duration", im.info.get("duration")) +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: + duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - im_frames = [] + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -435,80 +662,127 @@ def _write_multiple_frames(im, fp, palette): im_frame = _normalize_mode(im_frame.copy()) if frame_count == 0: for k, v in im_frame.info.items(): - im.encoderinfo.setdefault(k, v) - im_frame = _normalize_palette(im_frame, palette, im.encoderinfo) + if k == "transparency": + continue + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) encoderinfo = im.encoderinfo.copy() + if "transparency" in im_frame.info: + encoderinfo.setdefault("transparency", im_frame.info["transparency"]) + im_frame = _normalize_palette(im_frame, palette, encoderinfo) if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] if isinstance(disposal, (list, tuple)): encoderinfo["disposal"] = disposal[frame_count] frame_count += 1 - if im_frames: + diff_frame = None + if im_frames and previous_im: # delta frame - previous = im_frames[-1] - if encoderinfo.get("disposal") == 2: - if background_im is None: - background = _get_background( - im, - im.encoderinfo.get("background", im.info.get("background")), - ) - background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) - base_im = background_im - else: - base_im = previous["im"] - if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im): - delta = ImageChops.subtract_modulo(im_frame, base_im) - else: - delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") - ) - bbox = delta.getbbox() + delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame - if duration: - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + if encoderinfo.get("duration"): + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue + if im_frames[-1].encoderinfo.get("disposal") == 2: + if background_im is None: + color = im.encoderinfo.get( + "transparency", im.info.get("transparency", (0, 0, 0)) + ) + background = _get_background(im_frame, color) + background_im = Image.new("P", im_frame.size, background) + first_palette = im_frames[0].im.palette + assert first_palette is not None + background_im.putpalette(first_palette, first_palette.mode) + bbox = _getbbox(background_im, im_frame)[1] + elif encoderinfo.get("optimize") and im_frame.mode != "1": + if "transparency" not in encoderinfo: + assert im_frame.palette is not None + try: + encoderinfo["transparency"] = ( + im_frame.palette._new_color_index(im_frame) + ) + except ValueError: + pass + if "transparency" in encoderinfo: + # When the delta is zero, fill the image with transparency + diff_frame = im_frame.copy() + fill = Image.new("P", delta.size, encoderinfo["transparency"]) + if delta.mode == "RGBA": + r, g, b, a = delta.split() + mask = ImageMath.lambda_eval( + lambda args: args["convert"]( + args["max"]( + args["max"]( + args["max"](args["r"], args["g"]), args["b"] + ), + args["a"], + ) + * 255, + "1", + ), + r=r, + g=g, + b=b, + a=a, + ) + else: + if delta.mode == "P": + # Convert to L without considering palette + delta_l = Image.new("L", delta.size) + delta_l.putdata(delta.getdata()) + delta = delta_l + mask = ImageMath.lambda_eval( + lambda args: args["convert"](args["im"] * 255, "1"), + im=delta, + ) + diff_frame.paste(fill, mask=ImageOps.invert(mask)) else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) - - if len(im_frames) > 1: - for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: - # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): - fp.write(s) - offset = (0, 0) - else: - # compress difference - frame_data["encoderinfo"]["include_color_table"] = True - - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) - return True - elif "duration" in im.encoderinfo and isinstance( - im.encoderinfo["duration"], (list, tuple) - ): - # Since multiple frames will not be written, add together the frame durations - im.encoderinfo["duration"] = sum(im.encoderinfo["duration"]) + previous_im = im_frame + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1: + if "duration" in im.encoderinfo: + # Since multiple frames will not be written, use the combined duration + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False + + for frame_data in im_frames: + im_frame = frame_data.im + if not frame_data.bbox: + # global header + for s in _get_global_header(im_frame, frame_data.encoderinfo): + fp.write(s) + offset = (0, 0) + else: + # compress difference + if not palette: + frame_data.encoderinfo["include_color_table"] = True + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) + return True -def _save_all(im, fp, filename): + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) -def _save(im, fp, filename, save_all=False): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: # header if "palette" in im.encoderinfo or "palette" in im.info: palette = im.encoderinfo.get("palette", im.info.get("palette")) else: palette = None - im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True) + im.encoderinfo.setdefault("optimize", True) if not save_all or not _write_multiple_frames(im, fp, palette): _write_single_frame(im, fp, palette) @@ -519,7 +793,7 @@ def _save(im, fp, filename, save_all=False): fp.flush() -def get_interlace(im): +def get_interlace(im: Image.Image) -> int: interlace = im.encoderinfo.get("interlace", 1) # workaround for @PIL153 @@ -529,24 +803,13 @@ def get_interlace(im): return interlace -def _write_local_header(fp, im, offset, flags): - transparent_color_exists = False +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: - pass - else: - transparency = int(transparency) - # optimize the block away if transparent color is not used - transparent_color_exists = True - - used_palette_colors = _get_optimize(im, im.encoderinfo) - if used_palette_colors is not None: - # adjust the transparency index after optimize - try: - transparency = used_palette_colors.index(transparency) - except ValueError: - transparent_color_exists = False + transparency = None if "duration" in im.encoderinfo: duration = int(im.encoderinfo["duration"] / 10) @@ -555,11 +818,9 @@ def _write_local_header(fp, im, offset, flags): disposal = int(im.encoderinfo.get("disposal", 0)) - if transparent_color_exists or duration != 0 or disposal: - packed_flag = 1 if transparent_color_exists else 0 + if transparency is not None or duration != 0 or disposal: + packed_flag = 1 if transparency is not None else 0 packed_flag |= disposal << 2 - if not transparent_color_exists: - transparency = 0 fp.write( b"!" @@ -567,31 +828,10 @@ def _write_local_header(fp, im, offset, flags): + o8(4) # length + o8(packed_flag) # packed fields + o16(duration) # duration - + o8(transparency) # transparency index + + o8(transparency or 0) # transparency index + o8(0) ) - if "comment" in im.encoderinfo and 1 <= len(im.encoderinfo["comment"]): - fp.write(b"!" + o8(254)) # extension intro - comment = im.encoderinfo["comment"] - if isinstance(comment, str): - comment = comment.encode() - for i in range(0, len(comment), 255): - subblock = comment[i : i + 255] - fp.write(o8(len(subblock)) + subblock) - fp.write(o8(0)) - if "loop" in im.encoderinfo: - number_of_loops = im.encoderinfo["loop"] - fp.write( - b"!" - + o8(255) # extension intro - + o8(11) - + b"NETSCAPE2.0" - + o8(3) - + o8(1) - + o16(number_of_loops) # number of loops - + o8(0) - ) include_color_table = im.encoderinfo.get("include_color_table") if include_color_table: palette_bytes = _get_palette_bytes(im) @@ -613,8 +853,7 @@ def _write_local_header(fp, im, offset, flags): fp.write(o8(8)) # bits -def _save_netpbm(im, fp, filename): - +def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -645,6 +884,7 @@ def _save_netpbm(im, fp, filename): ) # Allow ppmquant to receive SIGPIPE if ppmtogif exits + assert quant_proc.stdout is not None quant_proc.stdout.close() retcode = quant_proc.wait() @@ -666,7 +906,7 @@ def _save_netpbm(im, fp, filename): _FORCE_OPTIMIZE = False -def _get_optimize(im, info): +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: """ Palette optimization is a potentially expensive operation. @@ -677,7 +917,7 @@ def _get_optimize(im, info): :param info: encoderinfo :returns: list of indexes of palette entries in use, or None """ - if im.mode in ("P", "L") and info and info.get("optimize", 0): + if im.mode in ("P", "L") and info and info.get("optimize"): # Potentially expensive operation. # The palette saves 3 bytes per color not used, but palette @@ -696,14 +936,25 @@ def _get_optimize(im, info): if count: used_palette_colors.append(i) - if optimise or ( - len(used_palette_colors) <= 128 - and max(used_palette_colors) > len(used_palette_colors) + if optimise or max(used_palette_colors) >= len(used_palette_colors): + return used_palette_colors + + assert im.palette is not None + num_palette_colors = len(im.palette.palette) // Image.getmodebands( + im.palette.mode + ) + current_palette_size = 1 << (num_palette_colors - 1).bit_length() + if ( + # check that the palette would become smaller when saved + len(used_palette_colors) <= current_palette_size // 2 + # check that the palette is not already the smallest possible size + and current_palette_size > 2 ): return used_palette_colors + return None -def _get_color_table_size(palette_bytes): +def _get_color_table_size(palette_bytes: bytes) -> int: # calculate the palette size for the header if not palette_bytes: return 0 @@ -713,7 +964,7 @@ def _get_color_table_size(palette_bytes): return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1 -def _get_header_palette(palette_bytes): +def _get_header_palette(palette_bytes: bytes) -> bytes: """ Returns the palette, null padded to the next power of 2 (*3) bytes suitable for direct inclusion in the GIF header @@ -731,53 +982,73 @@ def _get_header_palette(palette_bytes): return palette_bytes -def _get_palette_bytes(im): +def _get_palette_bytes(im: Image.Image) -> bytes: """ Gets the palette for inclusion in the gif header :param im: Image object :returns: Bytes, len<=768 suitable for inclusion in gif header """ - return im.palette.palette + if not im.palette: + return b"" + + palette = bytes(im.palette.palette) + if im.palette.mode == "RGBA": + palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3)) + return palette -def _get_background(im, infoBackground): +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: background = 0 - if infoBackground: - background = infoBackground - if isinstance(background, tuple): + if info_background: + if isinstance(info_background, tuple): # WebPImagePlugin stores an RGBA value in info["background"] # So it must be converted to the same format as GifImagePlugin's # info["background"] - a global color table index - background = im.palette.getcolor(background) + assert im.palette is not None + try: + background = im.palette.getcolor(info_background, im) + except ValueError as e: + if str(e) not in ( + # If all 256 colors are in use, + # then there is no need for the background color + "cannot allocate more than 256 colors", + # Ignore non-opaque WebP background + "cannot add non-opaque RGBA color to RGB palette", + ): + raise + else: + background = info_background return background -def _get_global_header(im, info): +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: """Return a list of strings representing a GIF header""" # Header Block - # http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp version = b"87a" - for extensionKey in ["transparency", "duration", "loop", "comment"]: - if info and extensionKey in info: - if (extensionKey == "duration" and info[extensionKey] == 0) or ( - extensionKey == "comment" and not (1 <= len(info[extensionKey]) <= 255) - ): - continue - version = b"89a" - break - else: - if im.info.get("version") == b"89a": - version = b"89a" + if im.info.get("version") == b"89a" or ( + info + and ( + "transparency" in info + or info.get("loop") is not None + or info.get("duration") + or info.get("comment") + ) + ): + version = b"89a" background = _get_background(im, info.get("background")) palette_bytes = _get_palette_bytes(im) color_table_size = _get_color_table_size(palette_bytes) - return [ + header = [ b"GIF" # signature + version # version + o16(im.size[0]) # canvas width @@ -790,9 +1061,38 @@ def _get_global_header(im, info): # Global Color Table _get_header_palette(palette_bytes), ] + if info.get("loop") is not None: + header.append( + b"!" + + o8(255) # extension intro + + o8(11) + + b"NETSCAPE2.0" + + o8(3) + + o8(1) + + o16(info["loop"]) # number of loops + + o8(0) + ) + if info.get("comment"): + comment_block = b"!" + o8(254) # extension intro + + comment = info["comment"] + if isinstance(comment, str): + comment = comment.encode() + for i in range(0, len(comment), 255): + subblock = comment[i : i + 255] + comment_block += o8(len(subblock)) + subblock + comment_block += o8(0) + header.append(comment_block) + return header -def _write_frame_data(fp, im_frame, offset, params): + +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: try: im_frame.encoderinfo = params @@ -800,7 +1100,9 @@ def _write_frame_data(fp, im_frame, offset, params): _write_local_header(fp, im_frame, offset, 0) ImageFile._save( - im_frame, fp, [("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])] + im_frame, + fp, + [ImageFile._Tile("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])], ) fp.write(b"\0") # end of image data @@ -812,7 +1114,9 @@ def _write_frame_data(fp, im_frame, offset, params): # Legacy GIF utilities -def getheader(im, palette=None, info=None): +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: """ Legacy Method to get Gif data from image. @@ -824,11 +1128,11 @@ def getheader(im, palette=None, info=None): :returns: tuple of(list of header items, optimized palette) """ - used_palette_colors = _get_optimize(im, info) - if info is None: info = {} + used_palette_colors = _get_optimize(im, info) + if "background" not in info and "background" in im.info: info["background"] = im.info["background"] @@ -840,9 +1144,9 @@ def getheader(im, palette=None, info=None): return header, used_palette_colors -# To specify duration, add the time in milliseconds to getdata(), -# e.g. getdata(im_frame, duration=1000) -def getdata(im, offset=(0, 0), **params): +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: """ Legacy Method @@ -850,18 +1154,23 @@ def getdata(im, offset=(0, 0), **params): The first string is a local image header, the rest contains encoded image data. + To specify duration, add the time in milliseconds, + e.g. ``getdata(im_frame, duration=1000)`` + :param im: Image object - :param offset: Tuple of (x, y) pixels. Defaults to (0,0) - :param \\**params: E.g. duration or other encoder info parameters - :returns: List of Bytes containing gif encoded frame data + :param offset: Tuple of (x, y) pixels. Defaults to (0, 0) + :param \\**params: e.g. duration or other encoder info parameters + :returns: List of bytes containing GIF encoded frame data """ + from io import BytesIO - class Collector: + class Collector(BytesIO): data = [] - def write(self, data): + def write(self, data: Buffer) -> int: self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/dependencies/OSX/PIL/GimpGradientFile.py b/dependencies/OSX/PIL/GimpGradientFile.py index 7ab7f99..220eac5 100644 --- a/dependencies/OSX/PIL/GimpGradientFile.py +++ b/dependencies/OSX/PIL/GimpGradientFile.py @@ -18,9 +18,10 @@ the corresponding code in GIMP, written by Federico Mena Quintero. See the GIMP distribution for more information.) """ - +from __future__ import annotations from math import log, pi, sin, sqrt +from typing import IO, Callable from ._binary import o8 @@ -28,7 +29,7 @@ """""" # Enable auto-doc for data member -def linear(middle, pos): +def linear(middle: float, pos: float) -> float: if pos <= middle: if middle < EPSILON: return 0.0 @@ -43,19 +44,19 @@ def linear(middle, pos): return 0.5 + 0.5 * pos / middle -def curved(middle, pos): +def curved(middle: float, pos: float) -> float: return pos ** (log(0.5) / log(max(middle, EPSILON))) -def sine(middle, pos): +def sine(middle: float, pos: float) -> float: return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 -def sphere_increasing(middle, pos): +def sphere_increasing(middle: float, pos: float) -> float: return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) -def sphere_decreasing(middle, pos): +def sphere_decreasing(middle: float, pos: float) -> float: return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) @@ -64,18 +65,28 @@ def sphere_decreasing(middle, pos): class GradientFile: - - gradient = None - - def getpalette(self, entries=256): - + gradient: ( + list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] + | None + ) = None + + def getpalette(self, entries: int = 256) -> tuple[bytes, str]: + assert self.gradient is not None palette = [] ix = 0 x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix] for i in range(entries): - x = i / (entries - 1) while x1 < x: @@ -104,10 +115,10 @@ def getpalette(self, entries=256): class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" - def __init__(self, fp): - + def __init__(self, fp: IO[bytes]) -> None: if fp.readline()[:13] != b"GIMP Gradient": - raise SyntaxError("not a GIMP gradient file") + msg = "not a GIMP gradient file" + raise SyntaxError(msg) line = fp.readline() @@ -117,10 +128,9 @@ def __init__(self, fp): count = int(line) - gradient = [] + self.gradient = [] for i in range(count): - s = fp.readline().split() w = [float(x) for x in s[:11]] @@ -133,8 +143,7 @@ def __init__(self, fp): cspace = int(s[12]) if cspace != 0: - raise OSError("cannot handle HSV colour space") - - gradient.append((x0, x1, xm, rgb0, rgb1, segment)) + msg = "cannot handle HSV colour space" + raise OSError(msg) - self.gradient = gradient + self.gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/dependencies/OSX/PIL/GimpPaletteFile.py b/dependencies/OSX/PIL/GimpPaletteFile.py index 10fd3ad..4cad0eb 100644 --- a/dependencies/OSX/PIL/GimpPaletteFile.py +++ b/dependencies/OSX/PIL/GimpPaletteFile.py @@ -13,8 +13,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from typing import IO from ._binary import o8 @@ -24,33 +26,33 @@ class GimpPaletteFile: rawmode = "RGB" - def __init__(self, fp): - - self.palette = [o8(i) * 3 for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] if fp.readline()[:12] != b"GIMP Palette": - raise SyntaxError("not a GIMP palette file") + msg = "not a GIMP palette file" + raise SyntaxError(msg) for i in range(256): - s = fp.readline() if not s: break # skip fields and comment lines - if re.match(br"\w+:|#", s): + if re.match(rb"\w+:|#", s): continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = tuple(map(int, s.split()[:3])) if len(v) != 3: - raise ValueError("bad palette entry") - - self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) + msg = "bad palette entry" + raise ValueError(msg) - self.palette = b"".join(self.palette) + palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) - def getpalette(self): + self.palette = b"".join(palette) + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/dependencies/OSX/PIL/GribStubImagePlugin.py b/dependencies/OSX/PIL/GribStubImagePlugin.py index 515c272..e9aa084 100644 --- a/dependencies/OSX/PIL/GribStubImagePlugin.py +++ b/dependencies/OSX/PIL/GribStubImagePlugin.py @@ -8,14 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile -from ._binary import i8 _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific GRIB image handler. @@ -29,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): - return prefix[0:4] == b"GRIB" and i8(prefix[7]) == 1 +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"GRIB" and prefix[7] == 1 class GribStubImageFile(ImageFile.StubImageFile): - format = "GRIB" format_description = "GRIB" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not a GRIB file") + msg = "Not a GRIB file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("GRIB save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "GRIB save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/OSX/PIL/Hdf5StubImagePlugin.py b/dependencies/OSX/PIL/Hdf5StubImagePlugin.py index 362f2d3..cc9e73d 100644 --- a/dependencies/OSX/PIL/Hdf5StubImagePlugin.py +++ b/dependencies/OSX/PIL/Hdf5StubImagePlugin.py @@ -8,13 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific HDF5 image handler. @@ -28,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == b"\x89HDF\r\n\x1a\n" class HDF5StubImageFile(ImageFile.StubImageFile): - format = "HDF5" format_description = "HDF5" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not an HDF file") + msg = "Not an HDF file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("HDF5 save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "HDF5 save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/OSX/PIL/IcnsImagePlugin.py b/dependencies/OSX/PIL/IcnsImagePlugin.py index b9b1ae0..9757b2b 100644 --- a/dependencies/OSX/PIL/IcnsImagePlugin.py +++ b/dependencies/OSX/PIL/IcnsImagePlugin.py @@ -6,48 +6,55 @@ # # history: # 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies. +# 2020-04-04 Allow saving on all operating systems. # # Copyright (c) 2004 by Bob Ippolito. # Copyright (c) 2004 by Secret Labs. # Copyright (c) 2004 by Fredrik Lundh. # Copyright (c) 2014 by Alastair Houghton. +# Copyright (c) 2020 by Pan Jing. # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import os -import shutil import struct -import subprocess import sys -import tempfile +from typing import IO -from PIL import Image, ImageFile, PngImagePlugin, features -from PIL._binary import i8 +from . import Image, ImageFile, PngImagePlugin, features +from ._deprecate import deprecate enable_jpeg2k = features.check_codec("jpg_2000") if enable_jpeg2k: - from PIL import Jpeg2KImagePlugin + from . import Jpeg2KImagePlugin +MAGIC = b"icns" HEADERSIZE = 8 -def nextheader(fobj): +def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]: return struct.unpack(">4sI", fobj.read(HEADERSIZE)) -def read_32t(fobj, start_length, size): +def read_32t( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # The 128x128 icon seems to have an extra header for some reason. (start, length) = start_length fobj.seek(start) sig = fobj.read(4) if sig != b"\x00\x00\x00\x00": - raise SyntaxError("Unknown signature, expecting 0x00000000") + msg = "Unknown signature, expecting 0x00000000" + raise SyntaxError(msg) return read_32(fobj, (start + 4, length - 4), size) -def read_32(fobj, start_length, size): +def read_32( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: """ Read a 32bit RGB icon resource. Seems to be either uncompressed or an RLE packbits-like scheme. @@ -70,26 +77,29 @@ def read_32(fobj, start_length, size): byte = fobj.read(1) if not byte: break - byte = i8(byte) - if byte & 0x80: - blocksize = byte - 125 + byte_int = byte[0] + if byte_int & 0x80: + blocksize = byte_int - 125 byte = fobj.read(1) for i in range(blocksize): data.append(byte) else: - blocksize = byte + 1 + blocksize = byte_int + 1 data.append(fobj.read(blocksize)) bytesleft -= blocksize if bytesleft <= 0: break if bytesleft != 0: - raise SyntaxError(f"Error reading channel [{repr(bytesleft)} left]") + msg = f"Error reading channel [{repr(bytesleft)} left]" + raise SyntaxError(msg) band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1) im.im.putband(band.im, band_ix) return {"RGB": im} -def read_mk(fobj, start_length, size): +def read_mk( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # Alpha masks seem to be uncompressed start = start_length[0] fobj.seek(start) @@ -99,13 +109,18 @@ def read_mk(fobj, start_length, size): return {"A": band} -def read_png_or_jpeg2000(fobj, start_length, size): +def read_png_or_jpeg2000( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: (start, length) = start_length fobj.seek(start) sig = fobj.read(12) + + im: Image.Image if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": fobj.seek(start) im = PngImagePlugin.PngImageFile(fobj) + Image._decompression_bomb_check(im.size) return {"RGBA": im} elif ( sig[:4] == b"\xff\x4f\xff\x51" @@ -113,24 +128,26 @@ def read_png_or_jpeg2000(fobj, start_length, size): or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" ): if not enable_jpeg2k: - raise ValueError( + msg = ( "Unsupported icon subimage format (rebuild PIL " "with JPEG 2000 support to fix this)" ) + raise ValueError(msg) # j2k, jpc or j2c fobj.seek(start) jp2kstream = fobj.read(length) f = io.BytesIO(jp2kstream) im = Jpeg2KImagePlugin.Jpeg2KImageFile(f) + Image._decompression_bomb_check(im.size) if im.mode != "RGBA": im = im.convert("RGBA") return {"RGBA": im} else: - raise ValueError("Unsupported icon subimage format") + msg = "Unsupported icon subimage format" + raise ValueError(msg) class IcnsFile: - SIZES = { (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)], (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)], @@ -158,43 +175,46 @@ class IcnsFile: ], } - def __init__(self, fobj): + def __init__(self, fobj: IO[bytes]) -> None: """ fobj is a file-like object as an icns resource """ # signature : (start, length) - self.dct = dct = {} + self.dct = {} self.fobj = fobj sig, filesize = nextheader(fobj) - if sig != b"icns": - raise SyntaxError("not an icns file") + if not _accept(sig): + msg = "not an icns file" + raise SyntaxError(msg) i = HEADERSIZE while i < filesize: sig, blocksize = nextheader(fobj) if blocksize <= 0: - raise SyntaxError("invalid block header") + msg = "invalid block header" + raise SyntaxError(msg) i += HEADERSIZE blocksize -= HEADERSIZE - dct[sig] = (i, blocksize) + self.dct[sig] = (i, blocksize) fobj.seek(blocksize, io.SEEK_CUR) i += blocksize - def itersizes(self): + def itersizes(self) -> list[tuple[int, int, int]]: sizes = [] for size, fmts in self.SIZES.items(): - for (fmt, reader) in fmts: + for fmt, reader in fmts: if fmt in self.dct: sizes.append(size) break return sizes - def bestsize(self): + def bestsize(self) -> tuple[int, int, int]: sizes = self.itersizes() if not sizes: - raise SyntaxError("No 32bit icon resources found") + msg = "No 32bit icon resources found" + raise SyntaxError(msg) return max(sizes) - def dataforsize(self, size): + def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]: """ Get an icon resource as {channel: array}. Note that the arrays are bottom-up like windows bitmaps and will likely @@ -207,18 +227,20 @@ def dataforsize(self, size): dct.update(reader(self.fobj, desc, size)) return dct - def getimage(self, size=None): + def getimage( + self, size: tuple[int, int] | tuple[int, int, int] | None = None + ) -> Image.Image: if size is None: size = self.bestsize() - if len(size) == 2: + elif len(size) == 2: size = (size[0], size[1], 1) channels = self.dataforsize(size) - im = channels.get("RGBA", None) + im = channels.get("RGBA") if im: return im - im = channels.get("RGB").copy() + im = channels["RGB"].copy() try: im.putalpha(channels["A"]) except KeyError: @@ -244,9 +266,9 @@ class IcnsImageFile(ImageFile.ImageFile): format = "ICNS" format_description = "Mac OS icns resource" - def _open(self): + def _open(self) -> None: self.icns = IcnsFile(self.fp) - self.mode = "RGBA" + self._mode = "RGBA" self.info["sizes"] = self.icns.itersizes() self.best_size = self.icns.bestsize() self.size = ( @@ -254,130 +276,136 @@ def _open(self): self.best_size[1] * self.best_size[2], ) - @property - def size(self): + @property # type: ignore[override] + def size(self) -> tuple[int, int] | tuple[int, int, int]: return self._size @size.setter - def size(self, value): - info_size = value - if info_size not in self.info["sizes"] and len(info_size) == 2: - info_size = (info_size[0], info_size[1], 1) - if ( - info_size not in self.info["sizes"] - and len(info_size) == 3 - and info_size[2] == 1 - ): - simple_sizes = [ - (size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"] - ] - if value in simple_sizes: - info_size = self.info["sizes"][simple_sizes.index(value)] - if info_size not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") - self._size = value - - def load(self): - if len(self.size) == 3: - self.best_size = self.size - self.size = ( - self.best_size[0] * self.best_size[2], - self.best_size[1] * self.best_size[2], - ) - - Image.Image.load(self) - if self.im and self.im.size == self.size: + def size(self, value: tuple[int, int] | tuple[int, int, int]) -> None: + if len(value) == 3: + deprecate("Setting size to (width, height, scale)", 12, "load(scale)") + if value in self.info["sizes"]: + self._size = value # type: ignore[assignment] + return + else: + # Check that a matching size exists, + # or that there is a scale that would create a size that matches + for size in self.info["sizes"]: + simple_size = size[0] * size[2], size[1] * size[2] + scale = simple_size[0] // value[0] + if simple_size[1] / value[1] == scale: + self._size = value + return + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) + + def load(self, scale: int | None = None) -> Image.core.PixelAccess | None: + if scale is not None or len(self.size) == 3: + if scale is None and len(self.size) == 3: + scale = self.size[2] + assert scale is not None + width, height = self.size[:2] + self.size = width * scale, height * scale + self.best_size = width, height, scale + + px = Image.Image.load(self) + if self._im is not None and self.im.size == self.size: # Already loaded - return + return px self.load_prepare() # This is likely NOT the best way to do it, but whatever. im = self.icns.getimage(self.best_size) # If this is a PNG or JPEG 2000, it won't be loaded yet - im.load() + px = im.load() self.im = im.im - self.mode = im.mode + self._mode = im.mode self.size = im.size - self.load_end() + + return px -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: """ Saves the image as a series of PNG files, - that are then converted to a .icns file - using the macOS command line utility 'iconutil'. - - macOS only. + that are then combined into a .icns file. """ if hasattr(fp, "flush"): fp.flush() - # create the temporary set of pngs - with tempfile.TemporaryDirectory(".iconset") as iconset: - provided_images = { - im.width: im for im in im.encoderinfo.get("append_images", []) - } - last_w = None - second_path = None - for w in [16, 32, 128, 256, 512]: - prefix = f"icon_{w}x{w}" - - first_path = os.path.join(iconset, prefix + ".png") - if last_w == w: - shutil.copyfile(second_path, first_path) - else: - im_w = provided_images.get(w, im.resize((w, w), Image.LANCZOS)) - im_w.save(first_path) - - second_path = os.path.join(iconset, prefix + "@2x.png") - im_w2 = provided_images.get(w * 2, im.resize((w * 2, w * 2), Image.LANCZOS)) - im_w2.save(second_path) - last_w = w * 2 - - # iconutil -c icns -o {} {} - - fp_only = not filename - if fp_only: - f, filename = tempfile.mkstemp(".icns") - os.close(f) - convert_cmd = ["iconutil", "-c", "icns", "-o", filename, iconset] - convert_proc = subprocess.Popen( - convert_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + sizes = { + b"ic07": 128, + b"ic08": 256, + b"ic09": 512, + b"ic10": 1024, + b"ic11": 32, + b"ic12": 64, + b"ic13": 256, + b"ic14": 512, + } + provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])} + size_streams = {} + for size in set(sizes.values()): + image = ( + provided_images[size] + if size in provided_images + else im.resize((size, size)) ) - convert_proc.stdout.close() + temp = io.BytesIO() + image.save(temp, "png") + size_streams[size] = temp.getvalue() + + entries = [] + for type, size in sizes.items(): + stream = size_streams[size] + entries.append((type, HEADERSIZE + len(stream), stream)) + + # Header + fp.write(MAGIC) + file_length = HEADERSIZE # Header + file_length += HEADERSIZE + 8 * len(entries) # TOC + file_length += sum(entry[1] for entry in entries) + fp.write(struct.pack(">i", file_length)) + + # TOC + fp.write(b"TOC ") + fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) + for entry in entries: + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + + # Data + for entry in entries: + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + fp.write(entry[2]) - retcode = convert_proc.wait() + if hasattr(fp, "flush"): + fp.flush() - if retcode: - raise subprocess.CalledProcessError(retcode, convert_cmd) - if fp_only: - with open(filename, "rb") as f: - fp.write(f.read()) +def _accept(prefix: bytes) -> bool: + return prefix[:4] == MAGIC -Image.register_open(IcnsImageFile.format, IcnsImageFile, lambda x: x[:4] == b"icns") +Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept) Image.register_extension(IcnsImageFile.format, ".icns") -if sys.platform == "darwin": - Image.register_save(IcnsImageFile.format, _save) - - Image.register_mime(IcnsImageFile.format, "image/icns") - +Image.register_save(IcnsImageFile.format, _save) +Image.register_mime(IcnsImageFile.format, "image/icns") if __name__ == "__main__": - if len(sys.argv) < 2: - print("Syntax: python IcnsImagePlugin.py [file]") + print("Syntax: python3 IcnsImagePlugin.py [file]") sys.exit() with open(sys.argv[1], "rb") as fp: imf = IcnsImageFile(fp) for size in imf.info["sizes"]: - imf.size = size - imf.save("out-%s-%s-%s.png" % size) + width, height, scale = imf.size = size + imf.save(f"out-{width}-{height}-{scale}.png") with Image.open(sys.argv[1]) as im: im.save("out.png") if sys.platform == "windows": diff --git a/dependencies/OSX/PIL/IcoImagePlugin.py b/dependencies/OSX/PIL/IcoImagePlugin.py index 57bb17e..e879f18 100644 --- a/dependencies/OSX/PIL/IcoImagePlugin.py +++ b/dependencies/OSX/PIL/IcoImagePlugin.py @@ -20,17 +20,19 @@ # Icon format references: # * https://en.wikipedia.org/wiki/ICO_(file_format) # * https://msdn.microsoft.com/en-us/library/ms997538.aspx +from __future__ import annotations - -import struct import warnings from io import BytesIO from math import ceil, log +from typing import IO, NamedTuple from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin -from ._binary import i8 from ._binary import i16le as i16 from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o16le as o16 +from ._binary import o32le as o32 # # -------------------------------------------------------------------- @@ -38,42 +40,76 @@ _MAGIC = b"\0\0\1\0" -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(_MAGIC) # (2+2) + bmp = im.encoderinfo.get("bitmap_format") == "bmp" sizes = im.encoderinfo.get( "sizes", [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], ) + frames = [] + provided_ims = [im] + im.encoderinfo.get("append_images", []) width, height = im.size - sizes = filter( - lambda x: False - if (x[0] > width or x[1] > height or x[0] > 256 or x[1] > 256) - else True, - sizes, - ) - sizes = list(sizes) - fp.write(struct.pack(" width or size[1] > height or size[0] > 256 or size[1] > 256: + continue + + for provided_im in provided_ims: + if provided_im.size != size: + continue + frames.append(provided_im) + if bmp: + bits = BmpImagePlugin.SAVE[provided_im.mode][1] + bits_used = [bits] + for other_im in provided_ims: + if other_im.size != size: + continue + bits = BmpImagePlugin.SAVE[other_im.mode][1] + if bits not in bits_used: + # Another image has been supplied for this size + # with a different bit depth + frames.append(other_im) + bits_used.append(bits) + break + else: + # TODO: invent a more convenient method for proportional scalings + frame = provided_im.copy() + frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None) + frames.append(frame) + fp.write(o16(len(frames))) # idCount(2) + offset = fp.tell() + len(frames) * 16 + for frame in frames: + width, height = frame.size # 0 means 256 - fp.write(struct.pack("B", width if width < 256 else 0)) # bWidth(1) - fp.write(struct.pack("B", height if height < 256 else 0)) # bHeight(1) - fp.write(b"\0") # bColorCount(1) + fp.write(o8(width if width < 256 else 0)) # bWidth(1) + fp.write(o8(height if height < 256 else 0)) # bHeight(1) + + bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0) + fp.write(o8(colors)) # bColorCount(1) fp.write(b"\0") # bReserved(1) fp.write(b"\0\0") # wPlanes(2) - fp.write(struct.pack(" bool: return prefix[:4] == _MAGIC +class IconHeader(NamedTuple): + width: int + height: int + nb_color: int + reserved: int + planes: int + bpp: int + size: int + offset: int + dim: tuple[int, int] + square: int + color_depth: int + + class IcoFile: - def __init__(self, buf): + def __init__(self, buf: IO[bytes]) -> None: """ Parse image from file-like object containing ico file data """ @@ -94,88 +144,82 @@ def __init__(self, buf): # check magic s = buf.read(6) if not _accept(s): - raise SyntaxError("not an ICO file") + msg = "not an ICO file" + raise SyntaxError(msg) self.buf = buf self.entry = [] # Number of items in file - self.nb_items = i16(s[4:]) + self.nb_items = i16(s, 4) # Get headers for each item for i in range(self.nb_items): s = buf.read(16) - icon_header = { - "width": i8(s[0]), - "height": i8(s[1]), - "nb_color": i8(s[2]), # No. of colors in image (0 if >=8bpp) - "reserved": i8(s[3]), - "planes": i16(s[4:]), - "bpp": i16(s[6:]), - "size": i32(s[8:]), - "offset": i32(s[12:]), - } - # See Wikipedia - for j in ("width", "height"): - if not icon_header[j]: - icon_header[j] = 256 - - # See Wikipedia notes about color depth. - # We need this just to differ images with equal sizes - icon_header["color_depth"] = ( - icon_header["bpp"] - or ( - icon_header["nb_color"] != 0 - and ceil(log(icon_header["nb_color"], 2)) - ) - or 256 + width = s[0] or 256 + height = s[1] or 256 + + # No. of colors in image (0 if >=8bpp) + nb_color = s[2] + bpp = i16(s, 6) + icon_header = IconHeader( + width=width, + height=height, + nb_color=nb_color, + reserved=s[3], + planes=i16(s, 4), + bpp=i16(s, 6), + size=i32(s, 8), + offset=i32(s, 12), + dim=(width, height), + square=width * height, + # See Wikipedia notes about color depth. + # We need this just to differ images with equal sizes + color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256, ) - icon_header["dim"] = (icon_header["width"], icon_header["height"]) - icon_header["square"] = icon_header["width"] * icon_header["height"] - self.entry.append(icon_header) - self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) + self.entry = sorted(self.entry, key=lambda x: x.color_depth) # ICO images are usually squares - # self.entry = sorted(self.entry, key=lambda x: x['width']) - self.entry = sorted(self.entry, key=lambda x: x["square"]) - self.entry.reverse() + self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True) - def sizes(self): + def sizes(self) -> set[tuple[int, int]]: """ - Get a list of all available icon sizes and color depths. + Get a set of all available icon sizes and color depths. """ - return {(h["width"], h["height"]) for h in self.entry} + return {(h.width, h.height) for h in self.entry} - def getentryindex(self, size, bpp=False): - for (i, h) in enumerate(self.entry): - if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): + def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: + for i, h in enumerate(self.entry): + if size == h.dim and (bpp is False or bpp == h.color_depth): return i return 0 - def getimage(self, size, bpp=False): + def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image: """ Get an image from the icon """ return self.frame(self.getentryindex(size, bpp)) - def frame(self, idx): + def frame(self, idx: int) -> Image.Image: """ Get an image from frame idx """ header = self.entry[idx] - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) data = self.buf.read(8) - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) + im: Image.Image if data[:8] == PngImagePlugin._MAGIC: # png frame im = PngImagePlugin.PngImageFile(self.buf) + Image._decompression_bomb_check(im.size) else: # XOR + AND mask bmp frame im = BmpImagePlugin.DibImageFile(self.buf) @@ -184,17 +228,10 @@ def frame(self, idx): # change tile dimension to only encompass XOR image im._size = (im.size[0], int(im.size[1] / 2)) d, e, o, a = im.tile[0] - im.tile[0] = d, (0, 0) + im.size, o, a + im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a) # figure out where AND mask image starts - mode = a[0] - bpp = 8 - for k, v in BmpImagePlugin.BIT2MODE.items(): - if mode == v[1]: - bpp = k - break - - if 32 == bpp: + if header.bpp == 32: # 32-bit color depth icon image allows semitransparent areas # PIL's DIB format ignores transparency bits, recover them. # The DIB is packed in BGRX byte order where X is the alpha @@ -206,13 +243,19 @@ def frame(self, idx): alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] # convert to an 8bpp grayscale image - mask = Image.frombuffer( - "L", # 8bpp - im.size, # (w, h) - alpha_bytes, # source chars - "raw", # raw decoder - ("L", 0, -1), # 8bpp inverted, unpadded, reversed - ) + try: + mask = Image.frombuffer( + "L", # 8bpp + im.size, # (w, h) + alpha_bytes, # source chars + "raw", # raw decoder + ("L", 0, -1), # 8bpp inverted, unpadded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise else: # get AND image from end of bitmap w = im.size[0] @@ -223,26 +266,33 @@ def frame(self, idx): # the total mask data is # padded row size * height / bits per char - and_mask_offset = o + int(im.size[0] * im.size[1] * (bpp / 8.0)) total_bytes = int((w * im.size[1]) / 8) + and_mask_offset = header.offset + header.size - total_bytes self.buf.seek(and_mask_offset) mask_data = self.buf.read(total_bytes) # convert raw data to image - mask = Image.frombuffer( - "1", # 1 bpp - im.size, # (w, h) - mask_data, # source chars - "raw", # raw decoder - ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed - ) + try: + mask = Image.frombuffer( + "1", # 1 bpp + im.size, # (w, h) + mask_data, # source chars + "raw", # raw decoder + ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise # now we have two images, im is XOR image and mask is AND image # apply mask image as alpha channel - im = im.convert("RGBA") - im.putalpha(mask) + if mask: + im = im.convert("RGBA") + im.putalpha(mask) return im @@ -264,7 +314,8 @@ class IcoImageFile(ImageFile.ImageFile): Handles classic, XP and Vista icon formats. When saving, PNG compression is used. Support for this was only added in - Windows Vista. + Windows Vista. If you are unable to view the icon in Windows, convert the + image to "RGBA" mode before saving. This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis . @@ -274,31 +325,34 @@ class IcoImageFile(ImageFile.ImageFile): format = "ICO" format_description = "Windows Icon" - def _open(self): + def _open(self) -> None: self.ico = IcoFile(self.fp) self.info["sizes"] = self.ico.sizes() - self.size = self.ico.entry[0]["dim"] + self.size = self.ico.entry[0].dim self.load() @property - def size(self): + def size(self) -> tuple[int, int]: return self._size @size.setter - def size(self, value): + def size(self, value: tuple[int, int]) -> None: if value not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) self._size = value - def load(self): - if self.im and self.im.size == self.size: + def load(self) -> Image.core.PixelAccess | None: + if self._im is not None and self.im.size == self.size: # Already loaded - return + return Image.Image.load(self) im = self.ico.getimage(self.size) # if tile is PNG, it won't really be loaded yet im.load() self.im = im.im - self.mode = im.mode + self._mode = im.mode + if im.palette: + self.palette = im.palette if im.size != self.size: warnings.warn("Image was not the expected size") @@ -308,8 +362,9 @@ def load(self): self.info["sizes"] = set(sizes) self.size = im.size + return None - def load_seek(self): + def load_seek(self, pos: int) -> None: # Flag the ImageFile.Parser so that it # just does all the decode at the end. pass diff --git a/dependencies/OSX/PIL/ImImagePlugin.py b/dependencies/OSX/PIL/ImImagePlugin.py index 8ef4741..b4215a0 100644 --- a/dependencies/OSX/PIL/ImImagePlugin.py +++ b/dependencies/OSX/PIL/ImImagePlugin.py @@ -24,13 +24,13 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import re +from typing import IO, Any from . import Image, ImageFile, ImagePalette -from ._binary import i8 # -------------------------------------------------------------------- # Standard tags @@ -79,7 +79,7 @@ "LA image": ("LA", "LA;L"), "PA image": ("LA", "PA;L"), "RGBA image": ("RGBA", "RGBA;L"), - "RGBX image": ("RGBX", "RGBX;L"), + "RGBX image": ("RGB", "RGBX;L"), "CMYK image": ("CMYK", "CMYK;L"), "YCC image": ("YCbCr", "YCbCr;L"), } @@ -94,17 +94,17 @@ for i in ["32S"]: OPEN[f"L {i} image"] = ("I", f"I;{i}") OPEN[f"L*{i} image"] = ("I", f"I;{i}") -for i in range(2, 33): - OPEN[f"L*{i} image"] = ("F", f"F;{i}") +for j in range(2, 33): + OPEN[f"L*{j} image"] = ("F", f"F;{j}") # -------------------------------------------------------------------- # Read IM directory -split = re.compile(br"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") +split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") -def number(s): +def number(s: Any) -> float: try: return int(s) except ValueError: @@ -116,18 +116,17 @@ def number(s): class ImImageFile(ImageFile.ImageFile): - format = "IM" format_description = "IFUNC Image Memory" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # Quick rejection: if there's not an LF among the first # 100 bytes, this is (probably) not a text header. if b"\n" not in self.fp.read(100): - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) self.fp.seek(0) n = 0 @@ -140,7 +139,6 @@ def _open(self): self.rawmode = "L" while True: - s = self.fp.read(1) # Some versions of IFUNC uses \n\r instead of \r\n... @@ -154,7 +152,8 @@ def _open(self): s = s + self.fp.readline() if len(s) > 100: - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) if s[-2:] == b"\r\n": s = s[:-2] @@ -164,10 +163,10 @@ def _open(self): try: m = split.match(s) except re.error as e: - raise SyntaxError("not an IM file") from e + msg = "not an IM file" + raise SyntaxError(msg) from e if m: - k, v = m.group(1, 2) # Don't know if this is the correct encoding, @@ -198,23 +197,23 @@ def _open(self): n += 1 else: - - raise SyntaxError( - "Syntax error in IM header: " + s.decode("ascii", "replace") - ) + msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}" + raise SyntaxError(msg) if not n: - raise SyntaxError("Not an IM file") + msg = "Not an IM file" + raise SyntaxError(msg) # Basic attributes self._size = self.info[SIZE] - self.mode = self.info[MODE] + self._mode = self.info[MODE] # Skip forward to start of image data - while s and s[0:1] != b"\x1A": + while s and s[:1] != b"\x1A": s = self.fp.read(1) if not s: - raise SyntaxError("File truncated") + msg = "File truncated" + raise SyntaxError(msg) if LUT in self.info: # convert lookup table to palette or lut attribute @@ -223,39 +222,42 @@ def _open(self): linear = 1 # linear greyscale palette for i in range(256): if palette[i] == palette[i + 256] == palette[i + 512]: - if i8(palette[i]) != i: + if palette[i] != i: linear = 0 else: greyscale = 0 if self.mode in ["L", "LA", "P", "PA"]: if greyscale: if not linear: - self.lut = [i8(c) for c in palette[:256]] + self.lut = list(palette[:256]) else: if self.mode in ["L", "P"]: - self.mode = self.rawmode = "P" + self._mode = self.rawmode = "P" elif self.mode in ["LA", "PA"]: - self.mode = "PA" + self._mode = "PA" self.rawmode = "PA;L" self.palette = ImagePalette.raw("RGB;L", palette) elif self.mode == "RGB": if not greyscale or not linear: - self.lut = [i8(c) for c in palette] + self.lut = list(palette) self.frame = 0 self.__offset = offs = self.fp.tell() - self.__fp = self.fp # FIXME: hack + self._fp = self.fp # FIXME: hack if self.rawmode[:2] == "F;": - # ifunc95 formats try: # use bit decoder (if necessary) bits = int(self.rawmode[2:]) if bits not in [8, 16, 32]: - self.tile = [("bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1))] + self.tile = [ + ImageFile._Tile( + "bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1) + ) + ] return except ValueError: pass @@ -265,23 +267,27 @@ def _open(self): # ever stumbled upon such a file ;-) size = self.size[0] * self.size[1] self.tile = [ - ("raw", (0, 0) + self.size, offs, ("G", 0, -1)), - ("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), - ("raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), + ImageFile._Tile( + "raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1) + ), ] else: # LabEye/IFUNC files - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] @property - def n_frames(self): + def n_frames(self) -> int: return self.info[FRAMES] @property - def is_animated(self): + def is_animated(self) -> bool: return self.info[FRAMES] > 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return @@ -295,22 +301,15 @@ def seek(self, frame): size = ((self.size[0] * bits + 7) // 8) * self.size[1] offs = self.__offset + frame * size - self.fp = self.__fp + self.fp = self._fp - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # -------------------------------------------------------------------- @@ -337,12 +336,12 @@ def _close__fp(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: image_type, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as IM") from e + msg = f"Cannot save {im.mode} images as IM" + raise ValueError(msg) from e frames = im.encoderinfo.get("frames", 1) @@ -352,18 +351,28 @@ def _save(im, fp, filename): # or: SyntaxError("not an IM file") # 8 characters are used for "Name: " and "\r\n" # Keep just the filename, ditch the potentially overlong path + if isinstance(filename, bytes): + filename = filename.decode("ascii") name, ext = os.path.splitext(os.path.basename(filename)) name = "".join([name[: 92 - len(ext)], ext]) fp.write(f"Name: {name}\r\n".encode("ascii")) - fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) + fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii")) fp.write(f"File size (no of images): {frames}\r\n".encode("ascii")) if im.mode in ["P", "PA"]: fp.write(b"Lut: 1\r\n") fp.write(b"\000" * (511 - fp.tell()) + b"\032") if im.mode in ["P", "PA"]: - fp.write(im.im.getpalette("RGB", "RGB;L")) # 768 bytes - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) + im_palette = im.im.getpalette("RGB", "RGB;L") + colors = len(im_palette) // 3 + palette = b"" + for i in range(3): + palette += im_palette[colors * i : colors * (i + 1)] + palette += b"\x00" * (256 - colors) + fp.write(palette) # 768 bytes + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))] + ) # diff --git a/dependencies/OSX/PIL/Image.py b/dependencies/OSX/PIL/Image.py index f9a5d82..dff3d06 100644 --- a/dependencies/OSX/PIL/Image.py +++ b/dependencies/OSX/PIL/Image.py @@ -24,51 +24,52 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import atexit import builtins import io import logging import math -import numbers import os +import re import struct import sys import tempfile import warnings -import xml.etree.ElementTree -from collections.abc import Callable, MutableMapping -from pathlib import Path +from collections.abc import Callable, Iterator, MutableMapping, Sequence +from enum import IntEnum +from types import ModuleType +from typing import ( + IO, + TYPE_CHECKING, + Any, + Literal, + Protocol, + cast, +) # VERSION was removed in Pillow 6.0.0. -# PILLOW_VERSION is deprecated and will be removed in a future release. +# PILLOW_VERSION was removed in Pillow 9.0.0. # Use __version__ instead. from . import ( + ExifTags, ImageMode, TiffTags, UnidentifiedImageError, __version__, _plugins, - _raise_version_warning, ) -from ._binary import i8, i32le -from ._util import deferred_error, isPath - -if sys.version_info >= (3, 7): - - def __getattr__(name): - if name == "PILLOW_VERSION": - _raise_version_warning() - return __version__ - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -else: - - from . import PILLOW_VERSION - - # Silence warning - assert PILLOW_VERSION +from ._binary import i32le, o32be, o32le +from ._deprecate import deprecate +from ._util import DeferredError, is_path +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None logger = logging.getLogger(__name__) @@ -81,8 +82,10 @@ class DecompressionBombError(Exception): pass +WARN_POSSIBLE_FORMATS: bool = False + # Limit to around a quarter gigabyte for a 24-bit (3 bpp) image -MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) +MAX_IMAGE_PIXELS: int | None = int(1024 * 1024 * 1024 // 4 // 3) try: @@ -94,14 +97,15 @@ class DecompressionBombError(Exception): from . import _imaging as core if __version__ != getattr(core, "PILLOW_VERSION", None): - raise ImportError( + msg = ( "The _imaging extension was built for another version of Pillow or PIL:\n" f"Core version: {getattr(core, 'PILLOW_VERSION', None)}\n" f"Pillow version: {__version__}" ) + raise ImportError(msg) except ImportError as v: - core = deferred_error(ImportError("The _imaging C module is not installed.")) + core = DeferredError.new(ImportError("The _imaging C module is not installed.")) # Explanations for ways that we know we might have an import error if str(v).startswith("Module use of python"): # The _imaging C module is present, but not compiled for @@ -118,15 +122,7 @@ class DecompressionBombError(Exception): raise -# works everywhere, win for pypy, not cpython -USE_CFFI_ACCESS = hasattr(sys, "pypy_version_info") -try: - import cffi -except ImportError: - cffi = None - - -def isImageType(t): +def isImageType(t: Any) -> TypeGuard[Image]: """ Checks if an object is an image object. @@ -137,60 +133,79 @@ def isImageType(t): :param t: object to check if it's an image :returns: True if the object is an image """ + deprecate("Image.isImageType(im)", 12, "isinstance(im, Image.Image)") return hasattr(t, "im") # # Constants -NONE = 0 # transpose -FLIP_LEFT_RIGHT = 0 -FLIP_TOP_BOTTOM = 1 -ROTATE_90 = 2 -ROTATE_180 = 3 -ROTATE_270 = 4 -TRANSPOSE = 5 -TRANSVERSE = 6 +class Transpose(IntEnum): + FLIP_LEFT_RIGHT = 0 + FLIP_TOP_BOTTOM = 1 + ROTATE_90 = 2 + ROTATE_180 = 3 + ROTATE_270 = 4 + TRANSPOSE = 5 + TRANSVERSE = 6 + # transforms (also defined in Imaging.h) -AFFINE = 0 -EXTENT = 1 -PERSPECTIVE = 2 -QUAD = 3 -MESH = 4 +class Transform(IntEnum): + AFFINE = 0 + EXTENT = 1 + PERSPECTIVE = 2 + QUAD = 3 + MESH = 4 -# resampling filters (also defined in Imaging.h) -NEAREST = NONE = 0 -BOX = 4 -BILINEAR = LINEAR = 2 -HAMMING = 5 -BICUBIC = CUBIC = 3 -LANCZOS = ANTIALIAS = 1 -_filters_support = {BOX: 0.5, BILINEAR: 1.0, HAMMING: 1.0, BICUBIC: 2.0, LANCZOS: 3.0} +# resampling filters (also defined in Imaging.h) +class Resampling(IntEnum): + NEAREST = 0 + BOX = 4 + BILINEAR = 2 + HAMMING = 5 + BICUBIC = 3 + LANCZOS = 1 + + +_filters_support = { + Resampling.BOX: 0.5, + Resampling.BILINEAR: 1.0, + Resampling.HAMMING: 1.0, + Resampling.BICUBIC: 2.0, + Resampling.LANCZOS: 3.0, +} # dithers -NEAREST = NONE = 0 -ORDERED = 1 # Not yet implemented -RASTERIZE = 2 # Not yet implemented -FLOYDSTEINBERG = 3 # default +class Dither(IntEnum): + NONE = 0 + ORDERED = 1 # Not yet implemented + RASTERIZE = 2 # Not yet implemented + FLOYDSTEINBERG = 3 # default + # palettes/quantizers -WEB = 0 -ADAPTIVE = 1 +class Palette(IntEnum): + WEB = 0 + ADAPTIVE = 1 -MEDIANCUT = 0 -MAXCOVERAGE = 1 -FASTOCTREE = 2 -LIBIMAGEQUANT = 3 -# categories -NORMAL = 0 -SEQUENCE = 1 -CONTAINER = 2 +class Quantize(IntEnum): + MEDIANCUT = 0 + MAXCOVERAGE = 1 + FASTOCTREE = 2 + LIBIMAGEQUANT = 3 + + +module = sys.modules[__name__] +for enum in (Transpose, Transform, Resampling, Dither, Palette, Quantize): + for item in enum: + setattr(module, item.name, item.value) + if hasattr(core, "DEFAULT_STRATEGY"): DEFAULT_STRATEGY = core.DEFAULT_STRATEGY @@ -203,91 +218,73 @@ def isImageType(t): # -------------------------------------------------------------------- # Registries -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -SAVE_ALL = {} -EXTENSION = {} -DECODERS = {} -ENCODERS = {} +if TYPE_CHECKING: + import mmap + from xml.etree.ElementTree import Element + + from IPython.lib.pretty import PrettyPrinter + + from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin + from ._typing import CapsuleType, NumpyArray, StrOrBytesPath, TypeGuard +ID: list[str] = [] +OPEN: dict[ + str, + tuple[ + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + Callable[[bytes], bool | str] | None, + ], +] = {} +MIME: dict[str, str] = {} +SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +EXTENSION: dict[str, str] = {} +DECODERS: dict[str, type[ImageFile.PyDecoder]] = {} +ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {} # -------------------------------------------------------------------- -# Modes supported by this version - -_MODEINFO = { - # NOTE: this table will be removed in future versions. use - # getmode* functions or ImageMode descriptors instead. - # official modes - "1": ("L", "L", ("1",)), - "L": ("L", "L", ("L",)), - "I": ("L", "I", ("I",)), - "F": ("L", "F", ("F",)), - "P": ("P", "L", ("P",)), - "RGB": ("RGB", "L", ("R", "G", "B")), - "RGBX": ("RGB", "L", ("R", "G", "B", "X")), - "RGBA": ("RGB", "L", ("R", "G", "B", "A")), - "CMYK": ("RGB", "L", ("C", "M", "Y", "K")), - "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr")), - "LAB": ("RGB", "L", ("L", "A", "B")), - "HSV": ("RGB", "L", ("H", "S", "V")), - # Experimental modes include I;16, I;16L, I;16B, RGBa, BGR;15, and - # BGR;24. Use these modes only if you know exactly what you're - # doing... -} - -if sys.byteorder == "little": - _ENDIAN = "<" -else: - _ENDIAN = ">" - -_MODE_CONV = { - # official modes - "1": ("|b1", None), # Bits need to be extended to bytes - "L": ("|u1", None), - "LA": ("|u1", 2), - "I": (_ENDIAN + "i4", None), - "F": (_ENDIAN + "f4", None), - "P": ("|u1", None), - "RGB": ("|u1", 3), - "RGBX": ("|u1", 4), - "RGBA": ("|u1", 4), - "CMYK": ("|u1", 4), - "YCbCr": ("|u1", 3), - "LAB": ("|u1", 3), # UNDONE - unsigned |u1i1i1 - "HSV": ("|u1", 3), - # I;16 == I;16L, and I;32 == I;32L - "I;16": ("u2", None), - "I;16L": ("i2", None), - "I;16LS": ("u4", None), - "I;32L": ("i4", None), - "I;32LS": ("" + + +def _conv_type_shape(im: Image) -> tuple[tuple[int, ...], str]: + m = ImageMode.getmode(im.mode) + shape: tuple[int, ...] = (im.height, im.width) + extra = len(m.bands) + if extra != 1: + shape += (extra,) + return shape, m.typestr + + +MODES = [ + "1", + "CMYK", + "F", + "HSV", + "I", + "I;16", + "I;16B", + "I;16L", + "I;16N", + "L", + "LA", + "La", + "LAB", + "P", + "PA", + "RGB", + "RGBA", + "RGBa", + "RGBX", + "YCbCr", +] # raw modes that may be memory mapped. NOTE: if you change this, you # may have to modify the stride calculation in map.c too! _MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16L", "I;16B") -def getmodebase(mode): +def getmodebase(mode: str) -> str: """ Gets the "base" mode for given mode. This function returns "L" for images that contain grayscale data, and "RGB" for images that @@ -300,7 +297,7 @@ def getmodebase(mode): return ImageMode.getmode(mode).basemode -def getmodetype(mode): +def getmodetype(mode: str) -> str: """ Gets the storage type mode. Given a mode, this function returns a single-layer mode suitable for storing individual bands. @@ -312,7 +309,7 @@ def getmodetype(mode): return ImageMode.getmode(mode).basetype -def getmodebandnames(mode): +def getmodebandnames(mode: str) -> tuple[str, ...]: """ Gets a list of individual band names. Given a mode, this function returns a tuple containing the names of individual bands (use @@ -327,7 +324,7 @@ def getmodebandnames(mode): return ImageMode.getmode(mode).bands -def getmodebands(mode): +def getmodebands(mode: str) -> int: """ Gets the number of individual bands for this mode. @@ -344,8 +341,12 @@ def getmodebands(mode): _initialized = 0 -def preinit(): - """Explicitly load standard file format drivers.""" +def preinit() -> None: + """ + Explicitly loads BMP, GIF, JPEG, PPM and PPM file format drivers. + + It is called when opening or saving images. + """ global _initialized if _initialized >= 1: @@ -381,43 +382,44 @@ def preinit(): assert PngImagePlugin except ImportError: pass - # try: - # import TiffImagePlugin - # assert TiffImagePlugin - # except ImportError: - # pass _initialized = 1 -def init(): +def init() -> bool: """ Explicitly initializes the Python Imaging Library. This function loads all available file format drivers. + + It is called when opening or saving images if :py:meth:`~preinit()` is + insufficient, and by :py:meth:`~PIL.features.pilinfo`. """ global _initialized if _initialized >= 2: - return 0 + return False + parent_name = __name__.rpartition(".")[0] for plugin in _plugins: try: logger.debug("Importing %s", plugin) - __import__(f"PIL.{plugin}", globals(), locals(), []) + __import__(f"{parent_name}.{plugin}", globals(), locals(), []) except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) if OPEN or SAVE: _initialized = 2 - return 1 + return True + return False # -------------------------------------------------------------------- # Codec factories (used by tobytes/frombytes and ImageFile.load) -def _getdecoder(mode, decoder_name, args, extra=()): - +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: # tweak arguments if args is None: args = () @@ -433,14 +435,16 @@ def _getdecoder(mode, decoder_name, args, extra=()): try: # get decoder - decoder = getattr(core, decoder_name + "_decoder") + decoder = getattr(core, f"{decoder_name}_decoder") except AttributeError as e: - raise OSError(f"decoder {decoder_name} not available") from e + msg = f"decoder {decoder_name} not available" + raise OSError(msg) from e return decoder(mode, *args + extra) -def _getencoder(mode, encoder_name, args, extra=()): - +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: # tweak arguments if args is None: args = () @@ -456,9 +460,10 @@ def _getencoder(mode, encoder_name, args, extra=()): try: # get encoder - encoder = getattr(core, encoder_name + "_encoder") + encoder = getattr(core, f"{encoder_name}_encoder") except AttributeError as e: - raise OSError(f"encoder {encoder_name} not available") from e + msg = f"encoder {encoder_name} not available" + raise OSError(msg) from e return encoder(mode, *args + extra) @@ -466,51 +471,65 @@ def _getencoder(mode, encoder_name, args, extra=()): # Simple expression analyzer -def coerce_e(value): - return value if isinstance(value, _E) else _E(value) +class ImagePointTransform: + """ + Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than + 8 bits, this represents an affine transformation, where the value is multiplied by + ``scale`` and ``offset`` is added. + """ + + def __init__(self, scale: float, offset: float) -> None: + self.scale = scale + self.offset = offset + + def __neg__(self) -> ImagePointTransform: + return ImagePointTransform(-self.scale, -self.offset) + def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return ImagePointTransform( + self.scale + other.scale, self.offset + other.offset + ) + return ImagePointTransform(self.scale, self.offset + other) -class _E: - def __init__(self, data): - self.data = data + __radd__ = __add__ - def __add__(self, other): - return _E((self.data, "__add__", coerce_e(other).data)) + def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return self + -other - def __mul__(self, other): - return _E((self.data, "__mul__", coerce_e(other).data)) + def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return other + -self + def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale * other, self.offset * other) + + __rmul__ = __mul__ + + def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale / other, self.offset / other) -def _getscaleoffset(expr): - stub = ["stub"] - data = expr(_E(stub)).data - try: - (a, b, c) = data # simplified syntax - if a is stub and b == "__mul__" and isinstance(c, numbers.Number): - return c, 0.0 - if a is stub and b == "__add__" and isinstance(c, numbers.Number): - return 1.0, c - except TypeError: - pass - try: - ((a, b, c), d, e) = data # full syntax - if ( - a is stub - and b == "__mul__" - and isinstance(c, numbers.Number) - and d == "__add__" - and isinstance(e, numbers.Number) - ): - return c, e - except TypeError: - pass - raise ValueError("illegal expression") + +def _getscaleoffset( + expr: Callable[[ImagePointTransform], ImagePointTransform | float] +) -> tuple[float, float]: + a = expr(ImagePointTransform(1, 0)) + return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a) # -------------------------------------------------------------------- # Implementation wrapper +class SupportsGetData(Protocol): + def getdata( + self, + ) -> tuple[Transform, Sequence[int]]: ... + + class Image: """ This class represents an image object. To create @@ -523,39 +542,52 @@ class Image: * :py:func:`~PIL.Image.frombytes` """ - format = None - format_description = None + format: str | None = None + format_description: str | None = None _close_exclusive_fp_after_loading = True - def __init__(self): + def __init__(self) -> None: # FIXME: take "new" parameters / other image? # FIXME: turn mode and size into delegating properties? - self.im = None - self.mode = "" + self._im: core.ImagingCore | DeferredError | None = None + self._mode = "" self._size = (0, 0) - self.palette = None - self.info = {} - self.category = NORMAL + self.palette: ImagePalette.ImagePalette | None = None + self.info: dict[str | tuple[int, int], Any] = {} self.readonly = 0 - self.pyaccess = None - self._exif = None + self._exif: Exif | None = None + + @property + def im(self) -> core.ImagingCore: + if isinstance(self._im, DeferredError): + raise self._im.ex + assert self._im is not None + return self._im + + @im.setter + def im(self, im: core.ImagingCore) -> None: + self._im = im @property - def width(self): + def width(self) -> int: return self.size[0] @property - def height(self): + def height(self) -> int: return self.size[1] @property - def size(self): + def size(self) -> tuple[int, int]: return self._size - def _new(self, im): + @property + def mode(self) -> str: + return self._mode + + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im - new.mode = im.mode + new._mode = im.mode new._size = im.size if im.mode in ("P", "PA"): if self.palette: @@ -571,59 +603,64 @@ def _new(self, im): def __enter__(self): return self + def _close_fp(self): + if getattr(self, "_fp", False): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + def __exit__(self, *args): - if hasattr(self, "fp") and getattr(self, "_exclusive_fp", False): - if hasattr(self, "_close__fp"): - self._close__fp() - if self.fp: - self.fp.close() - self.fp = None + if hasattr(self, "fp"): + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None - def close(self): + def close(self) -> None: """ Closes the file pointer, if possible. This operation will destroy the image core and release its memory. The image data will be unusable afterward. - This function is only required to close images that have not - had their file read and closed by the - :py:meth:`~PIL.Image.Image.load` method. See - :ref:`file-handling` for more information. + This function is required to close images that have multiple frames or + have not had their file read and closed by the + :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for + more information. """ - try: - if hasattr(self, "_close__fp"): - self._close__fp() - if self.fp: - self.fp.close() - self.fp = None - except Exception as msg: - logger.debug("Error closing: %s", msg) + if hasattr(self, "fp"): + try: + self._close_fp() + self.fp = None + except Exception as msg: + logger.debug("Error closing: %s", msg) if getattr(self, "map", None): - self.map = None + self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a # deferred error that will better explain that the core image # object is gone. - self.im = deferred_error(ValueError("Operation on closed image")) + self._im = DeferredError(ValueError("Operation on closed image")) - def _copy(self): + def _copy(self) -> None: self.load() self.im = self.im.copy() - self.pyaccess = None self.readonly = 0 - def _ensure_mutable(self): + def _ensure_mutable(self) -> None: if self.readonly: self._copy() else: self.load() - def _dump(self, file=None, format=None, **options): + def _dump( + self, file: str | None = None, format: str | None = None, **options: Any + ) -> str: suffix = "" if format: - suffix = "." + format + suffix = f".{format}" if not file: f, filename = tempfile.mkstemp(suffix) @@ -642,69 +679,91 @@ def _dump(self, file=None, format=None, **options): return filename - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) return ( - self.__class__ is other.__class__ - and self.mode == other.mode + self.mode == other.mode and self.size == other.size and self.info == other.info - and self.category == other.category - and self.readonly == other.readonly and self.getpalette() == other.getpalette() and self.tobytes() == other.tobytes() ) - def __repr__(self): - return "<%s.%s image mode=%s size=%dx%d at 0x%X>" % ( - self.__class__.__module__, - self.__class__.__name__, - self.mode, - self.size[0], - self.size[1], - id(self), + def __repr__(self) -> str: + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]} " + f"at 0x{id(self):X}>" ) - def _repr_png_(self): - """iPython display hook support + def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: + """IPython plain text display support""" - :returns: png version of the image as bytes + # Same as __repr__ but without unpredictable id(self), + # to keep Jupyter notebook `text/plain` output stable. + p.text( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]}>" + ) + + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: + """Helper function for iPython display hook. + + :param image_format: Image format. + :returns: image as bytes, saved into the given format. """ b = io.BytesIO() - self.save(b, "PNG") + try: + self.save(b, image_format, **kwargs) + except Exception: + return None return b.getvalue() + def _repr_png_(self) -> bytes | None: + """iPython display hook support for PNG format. + + :returns: PNG version of the image as bytes + """ + return self._repr_image("PNG", compress_level=1) + + def _repr_jpeg_(self) -> bytes | None: + """iPython display hook support for JPEG format. + + :returns: JPEG version of the image as bytes + """ + return self._repr_image("JPEG") + @property - def __array_interface__(self): + def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: # numpy array interface support - new = {} - shape, typestr = _conv_type_shape(self) - new["shape"] = shape - new["typestr"] = typestr - new["version"] = 3 + new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} if self.mode == "1": # Binary images need to be extended from bits to bytes # See: https://github.com/python-pillow/Pillow/issues/350 new["data"] = self.tobytes("raw", "L") else: new["data"] = self.tobytes() + new["shape"], new["typestr"] = _conv_type_shape(self) return new - def __getstate__(self): - return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()] + def __getstate__(self) -> list[Any]: + im_data = self.tobytes() # load image first + return [self.info, self.mode, self.size, self.getpalette(), im_data] - def __setstate__(self, state): + def __setstate__(self, state: list[Any]) -> None: Image.__init__(self) - self.tile = [] - info, mode, size, palette, data = state + info, mode, size, palette, data = state[:5] self.info = info - self.mode = mode + self._mode = mode self._size = size self.im = core.new(mode, size) if mode in ("L", "LA", "P", "PA") and palette: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name="raw", *args): + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: """ Return image as a bytes object. @@ -717,37 +776,47 @@ def tobytes(self, encoder_name="raw", *args): :param encoder_name: What encoder to use. The default is to use the standard "raw" encoder. + + A list of C encoders can be seen under + codecs section of the function array in + :file:`_imaging.c`. Python encoders are + registered within the relevant plugins. :param args: Extra arguments to the encoder. :returns: A :py:class:`bytes` object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] - if encoder_name == "raw" and args == (): - args = self.mode + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode self.load() + if self.width == 0 or self.height == 0: + return b"" + # unpack data - e = _getencoder(self.mode, encoder_name, args) + e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) bufsize = max(65536, self.size[0] * 4) # see RawEncode.c - data = [] + output = [] while True: - l, s, d = e.encode(bufsize) - data.append(d) - if s: + bytes_consumed, errcode, data = e.encode(bufsize) + output.append(data) + if errcode: break - if s < 0: - raise RuntimeError(f"encoder error {s} in tobytes") + if errcode < 0: + msg = f"encoder error {errcode} in tobytes" + raise RuntimeError(msg) - return b"".join(data) + return b"".join(output) - def tobitmap(self, name="image"): + def tobitmap(self, name: str = "image") -> bytes: """ Returns the image converted to an X11 bitmap. @@ -760,7 +829,8 @@ def tobitmap(self, name="image"): self.load() if self.mode != "1": - raise ValueError("not a bitmap") + msg = "not a bitmap" + raise ValueError(msg) data = self.tobytes("xbm") return b"".join( [ @@ -772,7 +842,12 @@ def tobitmap(self, name="image"): ] ) - def frombytes(self, data, decoder_name="raw", *args): + def frombytes( + self, + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, + ) -> None: """ Loads this image with pixel data from a bytes object. @@ -780,25 +855,31 @@ def frombytes(self, data, decoder_name="raw", *args): but loads data into this image instead of creating a new image object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + if self.width == 0 or self.height == 0: + return + + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] # default format - if decoder_name == "raw" and args == (): - args = self.mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode # unpack data - d = _getdecoder(self.mode, decoder_name, args) + d = _getdecoder(self.mode, decoder_name, decoder_args) d.setimage(self.im) s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) - def load(self): + def load(self) -> core.PixelAccess | None: """ Allocates storage for the image and loads the pixel data. In normal cases, you don't need to call this method, since the @@ -811,33 +892,30 @@ def load(self): operations. See :ref:`file-handling` for more information. :returns: An image access object. - :rtype: :ref:`PixelAccess` or :py:class:`PIL.PyAccess` + :rtype: :py:class:`.PixelAccess` """ - if self.im and self.palette and self.palette.dirty: + if self._im is not None and self.palette and self.palette.dirty: # realize palette - self.im.putpalette(*self.palette.getdata()) + mode, arr = self.palette.getdata() + self.im.putpalette(self.palette.mode, mode, arr) self.palette.dirty = 0 - self.palette.mode = "RGB" self.palette.rawmode = None - if "transparency" in self.info: + if "transparency" in self.info and mode in ("LA", "PA"): if isinstance(self.info["transparency"], int): self.im.putpalettealpha(self.info["transparency"], 0) else: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" + else: + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) - if self.im: - if cffi and USE_CFFI_ACCESS: - if self.pyaccess: - return self.pyaccess - from . import PyAccess - - self.pyaccess = PyAccess.new(self, self.readonly) - if self.pyaccess: - return self.pyaccess + if self._im is not None: return self.im.pixel_access(self.readonly) + return None - def verify(self): + def verify(self) -> None: """ Verifies the contents of a file. For data read from a file, this method attempts to determine if the file is broken, without @@ -848,26 +926,32 @@ def verify(self): """ pass - def convert(self, mode=None, matrix=None, dither=None, palette=WEB, colors=256): + def convert( + self, + mode: str | None = None, + matrix: tuple[float, ...] | None = None, + dither: Dither | None = None, + palette: Palette = Palette.WEB, + colors: int = 256, + ) -> Image: """ Returns a converted copy of this image. For the "P" mode, this method translates pixels through the palette. If mode is omitted, a mode is chosen so that all information in the image and the palette can be represented without a palette. - The current version supports all possible conversions between - "L", "RGB" and "CMYK." The ``matrix`` argument only supports "L" - and "RGB". + This supports all possible conversions between "L", "RGB" and "CMYK". The + ``matrix`` argument only supports "L" and "RGB". - When translating a color image to greyscale (mode "L"), + When translating a color image to grayscale (mode "L"), the library uses the ITU-R 601-2 luma transform:: L = R * 299/1000 + G * 587/1000 + B * 114/1000 - The default method of converting a greyscale ("L") or "RGB" + The default method of converting a grayscale ("L") or "RGB" image into a bilevel (mode "1") image uses Floyd-Steinberg dither to approximate the original image luminosity levels. If - dither is :data:`NONE`, all values larger than 128 are set to 255 (white), + dither is ``None``, all values larger than 127 are set to 255 (white), all other values to 0 (black). To use other thresholds, use the :py:meth:`~PIL.Image.Image.point` method. @@ -875,59 +959,67 @@ def convert(self, mode=None, matrix=None, dither=None, palette=WEB, colors=256): this passes the operation to :py:meth:`~PIL.Image.Image.quantize`, and ``dither`` and ``palette`` are ignored. + When converting from "PA", if an "RGBA" palette is present, the alpha + channel from the image will be used instead of the values from the palette. + :param mode: The requested mode. See: :ref:`concept-modes`. :param matrix: An optional conversion matrix. If given, this should be 4- or 12-tuple containing floating point values. :param dither: Dithering method, used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". - Available methods are :data:`NONE` or :data:`FLOYDSTEINBERG` (default). - Note that this is not used when ``matrix`` is supplied. + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). Note that this is not used when ``matrix`` is supplied. :param palette: Palette to use when converting from mode "RGB" - to "P". Available palettes are :data:`WEB` or :data:`ADAPTIVE`. - :param colors: Number of colors to use for the :data:`ADAPTIVE` palette. - Defaults to 256. + to "P". Available palettes are :data:`Palette.WEB` or + :data:`Palette.ADAPTIVE`. + :param colors: Number of colors to use for the :data:`Palette.ADAPTIVE` + palette. Defaults to 256. :rtype: :py:class:`~PIL.Image.Image` :returns: An :py:class:`~PIL.Image.Image` object. """ + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + self.load() + has_transparency = "transparency" in self.info if not mode and self.mode == "P": # determine default mode if self.palette: mode = self.palette.mode else: mode = "RGB" + if mode == "RGB" and has_transparency: + mode = "RGBA" if not mode or (mode == self.mode and not matrix): return self.copy() - has_transparency = self.info.get("transparency") is not None if matrix: # matrix conversion if mode not in ("L", "RGB"): - raise ValueError("illegal conversion") + msg = "illegal conversion" + raise ValueError(msg) im = self.im.convert_matrix(mode, matrix) - new = self._new(im) + new_im = self._new(im) if has_transparency and self.im.bands == 3: - transparency = new.info["transparency"] + transparency = new_im.info["transparency"] - def convert_transparency(m, v): - v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 - return max(0, min(255, int(v))) + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) if mode == "L": transparency = convert_transparency(matrix, transparency) elif len(mode) == 3: transparency = tuple( - [ - convert_transparency( - matrix[i * 4 : i * 4 + 4], transparency - ) - for i in range(0, len(transparency)) - ] + convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) + for i in range(0, len(transparency)) ) - new.info["transparency"] = transparency - return new + new_im.info["transparency"] = transparency + return new_im if mode == "P" and self.mode == "RGBA": return self.quantize(colors) @@ -936,7 +1028,9 @@ def convert_transparency(m, v): delete_trns = False # transparency handling if has_transparency: - if self.mode in ("1", "L", "I", "RGB") and mode == "RGBA": + if (self.mode in ("1", "L", "I", "I;16") and mode in ("LA", "RGBA")) or ( + self.mode == "RGB" and mode in ("La", "LA", "RGBa", "RGBA") + ): # Use transparent conversion to promote from transparent # color to an alpha channel. new_im = self._new( @@ -956,27 +1050,36 @@ def convert_transparency(m, v): else: # get the new transparency color. # use existing conversions - trns_im = Image()._new(core.new(self.mode, (1, 1))) + trns_im = new(self.mode, (1, 1)) if self.mode == "P": - trns_im.putpalette(self.palette) + assert self.palette is not None + trns_im.putpalette(self.palette, self.palette.mode) if isinstance(t, tuple): + err = "Couldn't allocate a palette color for transparency" + assert trns_im.palette is not None try: - t = trns_im.palette.getcolor(t) - except Exception as e: - raise ValueError( - "Couldn't allocate a palette color for transparency" - ) from e - trns_im.putpixel((0, 0), t) - - if mode in ("L", "RGB"): - trns_im = trns_im.convert(mode) + t = trns_im.palette.getcolor(t, self) + except ValueError as e: + if str(e) == "cannot allocate more than 256 colors": + # If all 256 colors are in use, + # then there is no need for transparency + t = None + else: + raise ValueError(err) from e + if t is None: + trns = None else: - # can't just retrieve the palette number, got to do it - # after quantization. - trns_im = trns_im.convert("RGB") - trns = trns_im.getpixel((0, 0)) + trns_im.putpixel((0, 0), t) - elif self.mode == "P" and mode == "RGBA": + if mode in ("L", "RGB"): + trns_im = trns_im.convert(mode) + else: + # can't just retrieve the palette number, got to do it + # after quantization. + trns_im = trns_im.convert("RGB") + trns = trns_im.getpixel((0, 0)) + + elif self.mode == "P" and mode in ("LA", "PA", "RGBA"): t = self.info["transparency"] delete_trns = True @@ -985,117 +1088,181 @@ def convert_transparency(m, v): elif isinstance(t, int): self.im.putpalettealpha(t, 0) else: - raise ValueError("Transparency for P mode should be bytes or int") + msg = "Transparency for P mode should be bytes or int" + raise ValueError(msg) - if mode == "P" and palette == ADAPTIVE: + if mode == "P" and palette == Palette.ADAPTIVE: im = self.im.quantize(colors) - new = self._new(im) + new_im = self._new(im) from . import ImagePalette - new.palette = ImagePalette.raw("RGB", new.im.getpalette("RGB")) + new_im.palette = ImagePalette.ImagePalette( + "RGB", new_im.im.getpalette("RGB") + ) if delete_trns: # This could possibly happen if we requantize to fewer colors. # The transparency would be totally off in that case. - del new.info["transparency"] + del new_im.info["transparency"] if trns is not None: try: - new.info["transparency"] = new.palette.getcolor(trns) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), # trns was converted to RGB + new_im, + ) except Exception: # if we can't make a transparent color, don't leave the old # transparency hanging around to mess us up. - del new.info["transparency"] + del new_im.info["transparency"] warnings.warn("Couldn't allocate palette entry for transparency") - return new + return new_im + + if "LAB" in (self.mode, mode): + im = self + if mode == "LAB": + if im.mode not in ("RGB", "RGBA", "RGBX"): + im = im.convert("RGBA") + other_mode = im.mode + else: + other_mode = mode + if other_mode in ("RGB", "RGBA", "RGBX"): + from . import ImageCms + + srgb = ImageCms.createProfile("sRGB") + lab = ImageCms.createProfile("LAB") + profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab] + transform = ImageCms.buildTransform( + profiles[0], profiles[1], im.mode, mode + ) + return transform.apply(im) # colorspace conversion if dither is None: - dither = FLOYDSTEINBERG + dither = Dither.FLOYDSTEINBERG try: im = self.im.convert(mode, dither) except ValueError: try: # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) + modebase = getmodebase(self.mode) + if modebase == self.mode: + raise + im = self.im.convert(modebase) im = im.convert(mode, dither) except KeyError as e: - raise ValueError("illegal conversion") from e + msg = "illegal conversion" + raise ValueError(msg) from e new_im = self._new(im) + if mode == "P" and palette != Palette.ADAPTIVE: + from . import ImagePalette + + new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB")) if delete_trns: # crash fail if we leave a bytes transparency in an rgb/l mode. del new_im.info["transparency"] if trns is not None: - if new_im.mode == "P": + if new_im.mode == "P" and new_im.palette: try: - new_im.info["transparency"] = new_im.palette.getcolor(trns) - except Exception: + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), new_im # trns was converted to RGB + ) + except ValueError as e: del new_im.info["transparency"] - warnings.warn("Couldn't allocate palette entry for transparency") + if str(e) != "cannot allocate more than 256 colors": + # If all 256 colors are in use, + # then there is no need for transparency + warnings.warn( + "Couldn't allocate palette entry for transparency" + ) else: new_im.info["transparency"] = trns return new_im - def quantize(self, colors=256, method=None, kmeans=0, palette=None, dither=1): + def quantize( + self, + colors: int = 256, + method: int | None = None, + kmeans: int = 0, + palette: Image | None = None, + dither: Dither = Dither.FLOYDSTEINBERG, + ) -> Image: """ Convert the image to 'P' mode with the specified number of colors. :param colors: The desired number of colors, <= 256 - :param method: :data:`MEDIANCUT` (median cut), - :data:`MAXCOVERAGE` (maximum coverage), - :data:`FASTOCTREE` (fast octree), - :data:`LIBIMAGEQUANT` (libimagequant; check support using - :py:func:`PIL.features.check_feature` - with ``feature="libimagequant"``). - :param kmeans: Integer + :param method: :data:`Quantize.MEDIANCUT` (median cut), + :data:`Quantize.MAXCOVERAGE` (maximum coverage), + :data:`Quantize.FASTOCTREE` (fast octree), + :data:`Quantize.LIBIMAGEQUANT` (libimagequant; check support + using :py:func:`PIL.features.check_feature` with + ``feature="libimagequant"``). + + By default, :data:`Quantize.MEDIANCUT` will be used. + + The exception to this is RGBA images. :data:`Quantize.MEDIANCUT` + and :data:`Quantize.MAXCOVERAGE` do not support RGBA images, so + :data:`Quantize.FASTOCTREE` is used by default instead. + :param kmeans: Integer greater than or equal to zero. :param palette: Quantize to the palette of given :py:class:`PIL.Image.Image`. :param dither: Dithering method, used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". - Available methods are :data:`NONE` or :data:`FLOYDSTEINBERG` (default). - Default: 1 (legacy setting) + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). :returns: A new image - """ self.load() if method is None: # defaults: - method = 0 + method = Quantize.MEDIANCUT if self.mode == "RGBA": - method = 2 + method = Quantize.FASTOCTREE - if self.mode == "RGBA" and method not in (2, 3): + if self.mode == "RGBA" and method not in ( + Quantize.FASTOCTREE, + Quantize.LIBIMAGEQUANT, + ): # Caller specified an invalid mode. - raise ValueError( + msg = ( "Fast Octree (method == 2) and libimagequant (method == 3) " "are the only valid methods for quantizing RGBA images" ) + raise ValueError(msg) if palette: # use palette from reference image palette.load() if palette.mode != "P": - raise ValueError("bad mode for palette image") - if self.mode != "RGB" and self.mode != "L": - raise ValueError( - "only RGB or L mode images can be quantized to a palette" - ) + msg = "bad mode for palette image" + raise ValueError(msg) + if self.mode not in {"RGB", "L"}: + msg = "only RGB or L mode images can be quantized to a palette" + raise ValueError(msg) im = self.im.convert("P", dither, palette.im) - return self._new(im) + new_im = self._new(im) + assert palette.palette is not None + new_im.palette = palette.palette.copy() + return new_im + + if kmeans < 0: + msg = "kmeans must not be negative" + raise ValueError(msg) im = self._new(self.im.quantize(colors, method, kmeans)) from . import ImagePalette mode = im.im.getpalettemode() - im.palette = ImagePalette.ImagePalette(mode, im.im.getpalette(mode, mode)) + palette_data = im.im.getpalette(mode, mode)[: colors * len(mode)] + im.palette = ImagePalette.ImagePalette(mode, palette_data) return im - def copy(self): + def copy(self) -> Image: """ Copies this image. Use this method if you wish to paste things into an image, but still retain the original. @@ -1108,7 +1275,7 @@ def copy(self): __copy__ = copy - def crop(self, box=None): + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1124,10 +1291,19 @@ def crop(self, box=None): if box is None: return self.copy() + if box[2] < box[0]: + msg = "Coordinate 'right' is less than 'left'" + raise ValueError(msg) + elif box[3] < box[1]: + msg = "Coordinate 'lower' is less than 'upper'" + raise ValueError(msg) + self.load() return self._new(self._crop(self.im, box)) - def _crop(self, im, box): + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: """ Returns a rectangular region from the core image object im. @@ -1147,12 +1323,14 @@ def _crop(self, im, box): return im.crop((x0, y0, x1, y1)) - def draft(self, mode, size): + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the image that as closely as possible matches the given mode and size. For example, you can use this method to convert a color - JPEG to greyscale while loading it. + JPEG to grayscale while loading it. If any changes are made, returns a tuple with the chosen ``mode`` and ``box`` with coordinates of the original image within the altered one. @@ -1165,17 +1343,18 @@ def draft(self, mode, size): currently implemented only for JPEG and MPO images. :param mode: The requested mode. - :param size: The requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). """ pass - def _expand(self, xmargin, ymargin=None): + def _expand(self, xmargin: int, ymargin: int | None = None) -> Image: if ymargin is None: ymargin = xmargin self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) + return self._new(self.im.expand(xmargin, ymargin)) - def filter(self, filter): + def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: """ Filters this image using the given filter. For a list of available filters, see the :py:mod:`~PIL.ImageFilter` module. @@ -1187,23 +1366,22 @@ def filter(self, filter): self.load() - if isinstance(filter, Callable): + if callable(filter): filter = filter() if not hasattr(filter, "filter"): - raise TypeError( - "filter argument should be ImageFilter.Filter instance or class" - ) + msg = "filter argument should be ImageFilter.Filter instance or class" + raise TypeError(msg) multiband = isinstance(filter, ImageFilter.MultibandFilter) if self.im.bands == 1 or multiband: return self._new(filter.filter(self.im)) - ims = [] - for c in range(self.im.bands): - ims.append(self._new(filter.filter(self.im.getband(c)))) + ims = [ + self._new(filter.filter(self.im.getband(c))) for c in range(self.im.bands) + ] return merge(self.mode, ims) - def getbands(self): + def getbands(self) -> tuple[str, ...]: """ Returns a tuple containing the name of each band in this image. For example, ``getbands`` on an RGB image returns ("R", "G", "B"). @@ -1213,11 +1391,15 @@ def getbands(self): """ return ImageMode.getmode(self.mode).bands - def getbbox(self): + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. + :param alpha_only: Optional flag, defaulting to ``True``. + If ``True`` and the image has an alpha channel, trim transparent pixels. + Otherwise, trim pixels when all channels are zero. + Keyword-only argument. :returns: The bounding box is returned as a 4-tuple defining the left, upper, right, and lower pixel coordinate. See :ref:`coordinate-system`. If the image is completely empty, this @@ -1226,12 +1408,18 @@ def getbbox(self): """ self.load() - return self.im.getbbox() + return self.im.getbbox(alpha_only) - def getcolors(self, maxcolors=256): + def getcolors( + self, maxcolors: int = 256 + ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None: """ Returns a list of colors used in this image. + The colors will be in the image's mode. For example, an RGB image will + return a tuple of (red, green, blue) color values, and a P image will + return the index of the color in the palette. + :param maxcolors: Maximum number of colors. If this number is exceeded, this method returns None. The default limit is 256 colors. @@ -1241,16 +1429,13 @@ def getcolors(self, maxcolors=256): self.load() if self.mode in ("1", "L", "P"): h = self.im.histogram() - out = [] - for i in range(256): - if h[i]: - out.append((h[i], i)) + out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]] if len(out) > maxcolors: return None return out return self.im.getcolors(maxcolors) - def getdata(self, band=None): + def getdata(self, band: int | None = None) -> core.ImagingCore: """ Returns the contents of this image as a sequence object containing pixel values. The sequence object is flattened, so @@ -1273,9 +1458,9 @@ def getdata(self, band=None): return self.im.getband(band) return self.im # could be abused - def getextrema(self): + def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: """ - Gets the the minimum and maximum pixel values for each band in + Gets the minimum and maximum pixel values for each band in the image. :returns: For a single-band image, a 2-tuple containing the @@ -1285,40 +1470,136 @@ def getextrema(self): self.load() if self.im.bands > 1: - extrema = [] - for i in range(self.im.bands): - extrema.append(self.im.getband(i).getextrema()) - return tuple(extrema) + return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def getexif(self): + def getxmp(self) -> dict[str, Any]: + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + + def get_name(tag: str) -> str: + return re.sub("^{[^}]+}", "", tag) + + def get_value(element: Element) -> str | dict[str, Any] | None: + value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()} + children = list(element) + if children: + for child in children: + name = get_name(child.tag) + child_value = get_value(child) + if name in value: + if not isinstance(value[name], list): + value[name] = [value[name]] + value[name].append(child_value) + else: + value[name] = child_value + elif value: + if element.text: + value["text"] = element.text + else: + return element.text + return value + + if ElementTree is None: + warnings.warn("XMP data cannot be read without defusedxml dependency") + return {} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + return {get_name(root.tag): get_value(root)} + + def getexif(self) -> Exif: + """ + Gets EXIF data from the image. + + :returns: an :py:class:`~PIL.Image.Exif` object. + """ if self._exif is None: self._exif = Exif() + elif self._exif._loaded: + return self._exif + self._exif._loaded = True exif_info = self.info.get("exif") - if exif_info is None and "Raw profile type exif" in self.info: - exif_info = bytes.fromhex( - "".join(self.info["Raw profile type exif"].split("\n")[3:]) - ) - self._exif.load(exif_info) + if exif_info is None: + if "Raw profile type exif" in self.info: + exif_info = bytes.fromhex( + "".join(self.info["Raw profile type exif"].split("\n")[3:]) + ) + elif hasattr(self, "tag_v2"): + self._exif.bigtiff = self.tag_v2._bigtiff + self._exif.endian = self.tag_v2._endian + self._exif.load_from_fp(self.fp, self.tag_v2._offset) + if exif_info is not None: + self._exif.load(exif_info) # XMP tags - if 0x0112 not in self._exif: + if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") if xmp_tags: - root = xml.etree.ElementTree.fromstring(xmp_tags) - for elem in root.iter(): - if elem.tag.endswith("}Description"): - orientation = elem.attrib.get( - "{http://ns.adobe.com/tiff/1.0/}Orientation" - ) - if orientation: - self._exif[0x0112] = int(orientation) - break + match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) + if match: + self._exif[ExifTags.Base.Orientation] = int(match[2]) return self._exif - def getim(self): + def _reload_exif(self) -> None: + if self._exif is None or not self._exif._loaded: + return + self._exif._loaded = False + self.getexif() + + def get_child_images(self) -> list[ImageFile.ImageFile]: + child_images = [] + exif = self.getexif() + ifds = [] + if ExifTags.Base.SubIFDs in exif: + subifd_offsets = exif[ExifTags.Base.SubIFDs] + if subifd_offsets: + if not isinstance(subifd_offsets, tuple): + subifd_offsets = (subifd_offsets,) + for subifd_offset in subifd_offsets: + ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset)) + ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) + if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset): + assert exif._info is not None + ifds.append((ifd1, exif._info.next)) + + offset = None + for ifd, ifd_offset in ifds: + current_offset = self.fp.tell() + if offset is None: + offset = current_offset + + fp = self.fp + if ifd is not None: + thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset) + if thumbnail_offset is not None: + thumbnail_offset += getattr(self, "_exif_offset", 0) + self.fp.seek(thumbnail_offset) + data = self.fp.read(ifd.get(ExifTags.Base.JpegIFByteCount)) + fp = io.BytesIO(data) + + with open(fp) as im: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): + im._frame_pos = [ifd_offset] + im._seek(0) + im.load() + child_images.append(im) + + if offset is not None: + self.fp.seek(offset) + return child_images + + def getim(self) -> CapsuleType: """ Returns a capsule that points to the internal image memory. @@ -1328,21 +1609,77 @@ def getim(self): self.load() return self.im.ptr - def getpalette(self): + def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None: """ Returns the image palette as a list. + :param rawmode: The mode in which to return the palette. ``None`` will + return the palette in its current mode. + + .. versionadded:: 9.1.0 + :returns: A list of color values [r, g, b, ...], or None if the image has no palette. """ self.load() try: - return list(self.im.getpalette()) + mode = self.im.getpalettemode() except ValueError: return None # no palette + if rawmode is None: + rawmode = mode + return list(self.im.getpalette(mode, rawmode)) - def getpixel(self, xy): + @property + def has_transparency_data(self) -> bool: + """ + Determine if an image has transparency data, whether in the form of an + alpha channel, a palette with an alpha channel, or a "transparency" key + in the info dictionary. + + Note the image might still appear solid, if all of the values shown + within are opaque. + + :returns: A boolean. + """ + if ( + self.mode in ("LA", "La", "PA", "RGBA", "RGBa") + or "transparency" in self.info + ): + return True + if self.mode == "P": + assert self.palette is not None + return self.palette.mode.endswith("A") + return False + + def apply_transparency(self) -> None: + """ + If a P mode image has a "transparency" key in the info dictionary, + remove the key and instead apply the transparency to the palette. + Otherwise, the image is unchanged. + """ + if self.mode != "P" or "transparency" not in self.info: + return + + from . import ImagePalette + + palette = self.getpalette("RGBA") + assert palette is not None + transparency = self.info["transparency"] + if isinstance(transparency, bytes): + for i, alpha in enumerate(transparency): + palette[i * 4 + 3] = alpha + else: + palette[transparency * 4 + 3] = 0 + self.palette = ImagePalette.ImagePalette("RGBA", bytes(palette)) + self.palette.dirty = 1 + + del self.info["transparency"] + + def getpixel( + self, xy: tuple[int, int] | list[int] + ) -> float | tuple[int, ...] | None: """ Returns the pixel value at a given position. @@ -1353,11 +1690,9 @@ def getpixel(self, xy): """ self.load() - if self.pyaccess: - return self.pyaccess.getpixel(xy) - return self.im.getpixel(xy) + return self.im.getpixel(tuple(xy)) - def getprojection(self): + def getprojection(self) -> tuple[list[int], list[int]]: """ Get projection to x and y axes @@ -1367,23 +1702,26 @@ def getprojection(self): self.load() x, y = self.im.getprojection() - return [i8(c) for c in x], [i8(c) for c in y] + return list(x), list(y) - def histogram(self, mask=None, extrema=None): + def histogram( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> list[int]: """ - Returns a histogram for the image. The histogram is returned as - a list of pixel counts, one for each pixel value in the source - image. If the image has more than one band, the histograms for - all bands are concatenated (for example, the histogram for an - "RGB" image contains 768 values). - - A bilevel image (mode "1") is treated as a greyscale ("L") image + Returns a histogram for the image. The histogram is returned as a + list of pixel counts, one for each pixel value in the source + image. Counts are grouped into 256 bins for each band, even if + the image has more than 8 bits per band. If the image has more + than one band, the histograms for all bands are concatenated (for + example, the histogram for an "RGB" image contains 768 values). + + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method returns a histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be either a - bi-level image (mode "1") or a greyscale image ("L"). + bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1394,22 +1732,24 @@ def histogram(self, mask=None, extrema=None): mask.load() return self.im.histogram((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.histogram(extrema) + return self.im.histogram( + extrema if extrema is not None else self.getextrema() + ) return self.im.histogram() - def entropy(self, mask=None, extrema=None): + def entropy( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> float: """ Calculates and returns the entropy for the image. - A bilevel image (mode "1") is treated as a greyscale ("L") + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method employs the histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be - either a bi-level image (mode "1") or a greyscale image ("L"). + either a bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1420,12 +1760,17 @@ def entropy(self, mask=None, extrema=None): mask.load() return self.im.entropy((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.entropy(extrema) + return self.im.entropy( + extrema if extrema is not None else self.getextrema() + ) return self.im.entropy() - def paste(self, im, box=None, mask=None): + def paste( + self, + im: Image | str | float | tuple[float, ...], + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -1443,8 +1788,8 @@ def paste(self, im, box=None, mask=None): also use color strings as supported by the ImageColor module. If a mask is given, this method updates only the regions - indicated by the mask. You can use either "1", "L" or "RGBA" - images (in the latter case, the alpha band is used as mask). + indicated by the mask. You can use either "1", "L", "LA", "RGBA" + or "RGBa" images (if present, the alpha band is used as mask). Where the mask is 255, the given image is copied as is. Where the mask is 0, the current value is preserved. Intermediate values will mix the two images together, including their alpha @@ -1453,7 +1798,7 @@ def paste(self, im, box=None, mask=None): See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to combine images with respect to their alpha channels. - :param im: Source image or pixel value (integer or tuple). + :param im: Source image or pixel value (integer, float or tuple). :param box: An optional 4-tuple giving the region to paste into. If a 2-tuple is used instead, it's treated as the upper left corner. If omitted or None, the source is pasted into the @@ -1465,7 +1810,10 @@ def paste(self, im, box=None, mask=None): :param mask: An optional mask image. """ - if isImageType(box) and mask is None: + if isinstance(box, Image): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None @@ -1475,37 +1823,42 @@ def paste(self, im, box=None, mask=None): if len(box) == 2: # upper left corner given; get size from image or mask - if isImageType(im): + if isinstance(im, Image): size = im.size - elif isImageType(mask): + elif isinstance(mask, Image): size = mask.size else: # FIXME: use self.size here? - raise ValueError("cannot determine region size; use 4-item box") + msg = "cannot determine region size; use 4-item box" + raise ValueError(msg) box += (box[0] + size[0], box[1] + size[1]) + source: core.ImagingCore | str | float | tuple[float, ...] if isinstance(im, str): from . import ImageColor - im = ImageColor.getcolor(im, self.mode) - - elif isImageType(im): + source = ImageColor.getcolor(im, self.mode) + elif isinstance(im, Image): im.load() if self.mode != im.mode: - if self.mode != "RGB" or im.mode not in ("RGBA", "RGBa"): + if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"): # should use an adapter for this! im = im.convert(self.mode) - im = im.im + source = im.im + else: + source = im self._ensure_mutable() if mask: mask.load() - self.im.paste(im, box, mask.im) + self.im.paste(source, box, mask.im) else: - self.im.paste(im, box) + self.im.paste(source, box) - def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: """'In-place' analog of Image.alpha_composite. Composites an image onto this image. @@ -1520,29 +1873,35 @@ def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): """ if not isinstance(source, (list, tuple)): - raise ValueError("Source must be a tuple") + msg = "Source must be a list or tuple" + raise ValueError(msg) if not isinstance(dest, (list, tuple)): - raise ValueError("Destination must be a tuple") - if not len(source) in (2, 4): - raise ValueError("Source must be a 2 or 4-tuple") + msg = "Destination must be a list or tuple" + raise ValueError(msg) + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" + raise ValueError(msg) + if not len(dest) == 2: - raise ValueError("Destination must be a 2-tuple") + msg = "Destination must be a sequence of length 2" + raise ValueError(msg) if min(source) < 0: - raise ValueError("Source must be non-negative") - if min(dest) < 0: - raise ValueError("Destination must be non-negative") - - if len(source) == 2: - source = source + im.size + msg = "Source must be non-negative" + raise ValueError(msg) - # over image, crop if it's not the whole thing. - if source == (0, 0) + im.size: + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: overlay = im else: - overlay = im.crop(source) + overlay = im.crop(overlay_crop_box) # target for the paste - box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) # destination image. don't copy if we're using the whole image. if box == (0, 0) + self.size: @@ -1553,7 +1912,17 @@ def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode=None): + def point( + self, + lut: ( + Sequence[float] + | NumpyArray + | Callable[[int], float] + | Callable[[ImagePointTransform], ImagePointTransform | float] + | ImagePointHandler + ), + mode: str | None = None, + ) -> Image: """ Maps this image through a lookup table or function. @@ -1568,11 +1937,10 @@ def point(self, lut, mode=None): object:: class Example(Image.ImagePointHandler): - def point(self, data): + def point(self, im: Image) -> Image: # Return result - :param mode: Output mode (default is same as input). In the - current version, this can only be used if the source image - has mode "L" or "P", and the output has mode "1" or the + :param mode: Output mode (default is same as input). This can only be used if + the source image has mode "L" or "P", and the output has mode "1" or the source image mode is "I" and the output mode is "L". :returns: An :py:class:`~PIL.Image.Image` object. """ @@ -1588,26 +1956,30 @@ def point(self, data): # check if the function can be used with point_transform # UNDONE wiredfool -- I think this prevents us from ever doing # a gamma function point transform on > 8bit images. - scale, offset = _getscaleoffset(lut) + scale, offset = _getscaleoffset(lut) # type: ignore[arg-type] return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - lut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type] + else: + flatLut = lut if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case - raise ValueError("point operation not supported for this mode") + msg = "point operation not supported for this mode" + raise ValueError(msg) - return self._new(self.im.point(lut, mode)) + if mode != "F": + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) - def putalpha(self, alpha): + def putalpha(self, alpha: Image | int) -> None: """ Adds or replaces the alpha layer in this image. If the image does not have an alpha layer, it's converted to "LA" or "RGBA". The new layer must be either "L" or "1". :param alpha: The new alpha layer. This can either be an "L" or "1" - image having the same size as this image, or an integer or - other color value. + image having the same size as this image, or an integer. """ self._ensure_mutable() @@ -1622,22 +1994,24 @@ def putalpha(self, alpha): # do things the hard way im = self.im.convert(mode) if im.mode not in ("LA", "PA", "RGBA"): - raise ValueError from e # sanity check + msg = "alpha channel could not be added" + raise ValueError(msg) from e # sanity check self.im = im - self.pyaccess = None - self.mode = self.im.mode - except (KeyError, ValueError) as e: - raise ValueError("illegal image mode") from e + self._mode = self.im.mode + except KeyError as e: + msg = "illegal image mode" + raise ValueError(msg) from e if self.mode in ("LA", "PA"): band = 1 else: band = 3 - if isImageType(alpha): + if isinstance(alpha, Image): # alpha layer if alpha.mode not in ("1", "L"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) alpha.load() if alpha.mode == "1": alpha = alpha.convert("L") @@ -1653,15 +2027,21 @@ def putalpha(self, alpha): self.im.putband(alpha.im, band) - def putdata(self, data, scale=1.0, offset=0.0): + def putdata( + self, + data: Sequence[float] | Sequence[Sequence[int]] | core.ImagingCore | NumpyArray, + scale: float = 1.0, + offset: float = 0.0, + ) -> None: """ - Copies pixel data to this image. This method copies data from a - sequence object into the image, starting at the upper left - corner (0, 0), and continuing until either the image or the - sequence ends. The scale and offset values are used to adjust - the sequence values: **pixel = value*scale + offset**. - - :param data: A sequence object. + Copies pixel data from a flattened sequence object into the image. The + values should start at the upper left corner (0, 0), continue to the + end of the line, followed directly by the first value of the second + line, and so on. Data will be read until either the image or the + sequence ends. The scale and offset values are used to adjust the + sequence values: **pixel = value*scale + offset**. + + :param data: A flattened sequence object. :param scale: An optional scale value. The default is 1.0. :param offset: An optional offset value. The default is 0.0. """ @@ -1670,40 +2050,57 @@ def putdata(self, data, scale=1.0, offset=0.0): self.im.putdata(data, scale, offset) - def putpalette(self, data, rawmode="RGB"): + def putpalette( + self, + data: ImagePalette.ImagePalette | bytes | Sequence[int], + rawmode: str = "RGB", + ) -> None: """ - Attaches a palette to this image. The image must be a "P", - "PA", "L" or "LA" image, and the palette sequence must contain - 768 integer values, where each group of three values represent - the red, green, and blue values for the corresponding pixel - index. Instead of an integer sequence, you can use an 8-bit - string. + Attaches a palette to this image. The image must be a "P", "PA", "L" + or "LA" image. + + The palette sequence must contain at most 256 colors, made up of one + integer value for each channel in the raw mode. + For example, if the raw mode is "RGB", then it can contain at most 768 + values, made up of red, green and blue values for the corresponding pixel + index in the 256 colors. + If the raw mode is "RGBA", then it can contain at most 1024 values, + containing red, green, blue and alpha values. + + Alternatively, an 8-bit string may be used instead of an integer sequence. :param data: A palette sequence (either a list or a string). - :param rawmode: The raw mode of the palette. + :param rawmode: The raw mode of the palette. Either "RGB", "RGBA", or a mode + that can be transformed to "RGB" or "RGBA" (e.g. "R", "BGR;15", "RGBA;L"). """ from . import ImagePalette if self.mode not in ("L", "LA", "P", "PA"): - raise ValueError("illegal image mode") - self.load() + msg = "illegal image mode" + raise ValueError(msg) if isinstance(data, ImagePalette.ImagePalette): - palette = ImagePalette.raw(data.rawmode, data.palette) + if data.rawmode is not None: + palette = ImagePalette.raw(data.rawmode, data.palette) + else: + palette = ImagePalette.ImagePalette(palette=data.palette) + palette.dirty = 1 else: if not isinstance(data, bytes): data = bytes(data) palette = ImagePalette.raw(rawmode, data) - self.mode = "PA" if "A" in self.mode else "P" + self._mode = "PA" if "A" in self.mode else "P" self.palette = palette - self.palette.mode = "RGB" + self.palette.mode = "RGBA" if "A" in rawmode else "RGB" self.load() # install new palette - def putpixel(self, xy, value): + def putpixel( + self, xy: tuple[int, int], value: float | tuple[int, ...] | list[int] + ) -> None: """ Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for multi-band images. In addition to this, RGB and RGBA tuples are - accepted for P images. + accepted for P and PA images. Note that this method is relatively slow. For more extensive changes, use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw` @@ -1724,19 +2121,23 @@ def putpixel(self, xy, value): self._copy() self.load() - if self.pyaccess: - return self.pyaccess.putpixel(xy, value) - if ( - self.mode == "P" + self.mode in ("P", "PA") and isinstance(value, (list, tuple)) and len(value) in [3, 4] ): - # RGB or RGBA value for a P image - value = self.palette.getcolor(value) + # RGB or RGBA value for a P or PA image + if self.mode == "PA": + alpha = value[3] if len(value) == 4 else 255 + value = value[:3] + assert self.palette is not None + palette_index = self.palette.getcolor(tuple(value), self) + value = (palette_index, alpha) if self.mode == "PA" else palette_index return self.im.putpixel(xy, value) - def remap_palette(self, dest_map, source_palette=None): + def remap_palette( + self, dest_map: list[int], source_palette: bytes | bytearray | None = None + ) -> Image: """ Rewrites the image to reorder the palette. @@ -1750,22 +2151,32 @@ def remap_palette(self, dest_map, source_palette=None): from . import ImagePalette if self.mode not in ("L", "P"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) + bands = 3 + palette_mode = "RGB" if source_palette is None: if self.mode == "P": - real_source_palette = self.im.getpalette("RGB")[:768] + self.load() + palette_mode = self.im.getpalettemode() + if palette_mode == "RGBA": + bands = 4 + source_palette = self.im.getpalette(palette_mode, palette_mode) else: # L-mode - real_source_palette = bytearray(i // 3 for i in range(768)) - else: - real_source_palette = source_palette + source_palette = bytearray(i // 3 for i in range(768)) + elif len(source_palette) > 768: + bands = 4 + palette_mode = "RGBA" palette_bytes = b"" new_positions = [0] * 256 # pick only the used colors from the palette for i, oldPosition in enumerate(dest_map): - palette_bytes += real_source_palette[oldPosition * 3 : oldPosition * 3 + 3] + palette_bytes += source_palette[ + oldPosition * bands : oldPosition * bands + bands + ] new_positions[oldPosition] = i # replace the palette color id of all pixel with the new id @@ -1789,29 +2200,37 @@ def remap_palette(self, dest_map, source_palette=None): mapping_palette = bytearray(new_positions) m_im = self.copy() - m_im.mode = "P" + m_im._mode = "P" m_im.palette = ImagePalette.ImagePalette( - "RGB", palette=mapping_palette * 3, size=768 + palette_mode, palette=mapping_palette * bands ) # possibly set palette dirty, then # m_im.putpalette(mapping_palette, 'L') # converts to 'P' # or just force it. # UNDONE -- this is part of the general issue with palettes - m_im.im.putpalette(*m_im.palette.getdata()) + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) m_im = m_im.convert("L") - # Internally, we require 768 bytes for a palette. - new_palette_bytes = palette_bytes + (768 - len(palette_bytes)) * b"\x00" - m_im.putpalette(new_palette_bytes) - m_im.palette = ImagePalette.ImagePalette( - "RGB", palette=palette_bytes, size=len(palette_bytes) - ) + m_im.putpalette(palette_bytes, palette_mode) + m_im.palette = ImagePalette.ImagePalette(palette_mode, palette=palette_bytes) + + if "transparency" in self.info: + try: + m_im.info["transparency"] = dest_map.index(self.info["transparency"]) + except ValueError: + if "transparency" in m_im.info: + del m_im.info["transparency"] return m_im - def _get_safe_box(self, size, resample, box): + def _get_safe_box( + self, + size: tuple[int, int], + resample: Resampling, + box: tuple[float, float, float, float], + ) -> tuple[int, int, int, int]: """Expands the box so it includes adjacent pixels that may be used by resampling with the given resampling filter. """ @@ -1828,20 +2247,27 @@ def _get_safe_box(self, size, resample, box): min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=BICUBIC, box=None, reducing_gap=None): + def resize( + self, + size: tuple[int, int] | list[int] | NumpyArray, + resample: int | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: """ Returns a resized copy of this image. - :param size: The requested size in pixels, as a 2-tuple: + :param size: The requested size in pixels, as a tuple or array: (width, height). :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.NEAREST`, :py:data:`PIL.Image.BOX`, - :py:data:`PIL.Image.BILINEAR`, :py:data:`PIL.Image.HAMMING`, - :py:data:`PIL.Image.BICUBIC` or :py:data:`PIL.Image.LANCZOS`. - Default filter is :py:data:`PIL.Image.BICUBIC`. - If the image has mode "1" or "P", it is - always set to :py:data:`PIL.Image.NEAREST`. - See: :ref:`concept-filters`. + one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If the image has mode "1" or "P", it is always set to + :py:data:`Resampling.NEAREST`. If the image mode is "BGR;15", + "BGR;16" or "BGR;24", then the default filter is + :py:data:`Resampling.NEAREST`. Otherwise, the default filter is + :py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. :param box: An optional 4-tuple of floats providing the source image region to be scaled. The values must be within (0, 0, width, height) rectangle. @@ -1861,57 +2287,65 @@ def resize(self, size, resample=BICUBIC, box=None, reducing_gap=None): :returns: An :py:class:`~PIL.Image.Image` object. """ - if resample not in (NEAREST, BILINEAR, BICUBIC, LANCZOS, BOX, HAMMING): - message = f"Unknown resampling filter ({resample})." + if resample is None: + bgr = self.mode.startswith("BGR;") + resample = Resampling.NEAREST if bgr else Resampling.BICUBIC + elif resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + Resampling.LANCZOS, + Resampling.BOX, + Resampling.HAMMING, + ): + msg = f"Unknown resampling filter ({resample})." filters = [ - "{} ({})".format(filter[1], filter[0]) + f"{filter[1]} ({filter[0]})" for filter in ( - (NEAREST, "Image.NEAREST"), - (LANCZOS, "Image.LANCZOS"), - (BILINEAR, "Image.BILINEAR"), - (BICUBIC, "Image.BICUBIC"), - (BOX, "Image.BOX"), - (HAMMING, "Image.HAMMING"), + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.LANCZOS, "Image.Resampling.LANCZOS"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), + (Resampling.BOX, "Image.Resampling.BOX"), + (Resampling.HAMMING, "Image.Resampling.HAMMING"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) if reducing_gap is not None and reducing_gap < 1.0: - raise ValueError("reducing_gap must be 1.0 or greater") - - size = tuple(size) + msg = "reducing_gap must be 1.0 or greater" + raise ValueError(msg) if box is None: box = (0, 0) + self.size - else: - box = tuple(box) + size = tuple(size) if self.size == size and box == (0, 0) + self.size: return self.copy() if self.mode in ("1", "P"): - resample = NEAREST + resample = Resampling.NEAREST - if self.mode in ["LA", "RGBA"]: - im = self.convert(self.mode[:-1] + "a") + if self.mode in ["LA", "RGBA"] and resample != Resampling.NEAREST: + im = self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) im = im.resize(size, resample, box) return im.convert(self.mode) self.load() - if reducing_gap is not None and resample != NEAREST: + if reducing_gap is not None and resample != Resampling.NEAREST: factor_x = int((box[2] - box[0]) / size[0] / reducing_gap) or 1 factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1 if factor_x > 1 or factor_y > 1: - reduce_box = self._get_safe_box(size, resample, box) + reduce_box = self._get_safe_box(size, cast(Resampling, resample), box) factor = (factor_x, factor_y) - if callable(self.reduce): - self = self.reduce(factor, box=reduce_box) - else: - self = Image.reduce(self, factor, box=reduce_box) + self = ( + self.reduce(factor, box=reduce_box) + if callable(self.reduce) + else Image.reduce(self, factor, box=reduce_box) + ) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, @@ -1921,7 +2355,11 @@ def resize(self, size, resample=BICUBIC, box=None, reducing_gap=None): return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor, box=None): + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -1939,14 +2377,12 @@ def reduce(self, factor, box=None): if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() if self.mode in ["LA", "RGBA"]: - im = self.convert(self.mode[:-1] + "a") + im = self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) im = im.reduce(factor, box) return im.convert(self.mode) @@ -1956,13 +2392,13 @@ def reduce(self, factor, box=None): def rotate( self, - angle, - resample=NEAREST, - expand=0, - center=None, - translate=None, - fillcolor=None, - ): + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, + center: tuple[float, float] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Returns a rotated copy of this image. This method returns a copy of this image, rotated the given number of degrees counter @@ -1970,12 +2406,12 @@ def rotate( :param angle: In degrees counter clockwise. :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.BICUBIC` - (cubic spline interpolation in a 4x4 environment). - If omitted, or if the image has mode "1" or "P", it is - set to :py:data:`PIL.Image.NEAREST`. See :ref:`concept-filters`. + one of :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline + interpolation in a 4x4 environment). If omitted, or if the image has + mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. + See :ref:`concept-filters`. :param expand: Optional expansion flag. If true, expands the output image to make it large enough to hold the entire rotated image. If false or omitted, make the output image the same size as the @@ -1996,11 +2432,11 @@ def rotate( if angle == 0: return self.copy() if angle == 180: - return self.transpose(ROTATE_180) - if angle == 90 and expand: - return self.transpose(ROTATE_90) - if angle == 270 and expand: - return self.transpose(ROTATE_270) + return self.transpose(Transpose.ROTATE_180) + if angle in (90, 270) and (expand or self.width == self.height): + return self.transpose( + Transpose.ROTATE_90 if angle == 90 else Transpose.ROTATE_270 + ) # Calculate the affine matrix. Note that this is the reverse # transformation (from destination image to source) because we @@ -2027,10 +2463,7 @@ def rotate( else: post_trans = translate if center is None: - # FIXME These should be rounded to ints? - rotn_center = (w / 2.0, h / 2.0) - else: - rotn_center = center + center = (w / 2, h / 2) angle = -math.radians(angle) matrix = [ @@ -2042,24 +2475,24 @@ def rotate( 0.0, ] - def transform(x, y, matrix): + def transform(x: float, y: float, matrix: list[float]) -> tuple[float, float]: (a, b, c, d, e, f) = matrix return a * x + b * y + c, d * x + e * y + f matrix[2], matrix[5] = transform( - -rotn_center[0] - post_trans[0], -rotn_center[1] - post_trans[1], matrix + -center[0] - post_trans[0], -center[1] - post_trans[1], matrix ) - matrix[2] += rotn_center[0] - matrix[5] += rotn_center[1] + matrix[2] += center[0] + matrix[5] += center[1] if expand: # calculate output size xx = [] yy = [] for x, y in ((0, 0), (w, 0), (w, h), (0, h)): - x, y = transform(x, y, matrix) - xx.append(x) - yy.append(y) + transformed_x, transformed_y = transform(x, y, matrix) + xx.append(transformed_x) + yy.append(transformed_y) nw = math.ceil(max(xx)) - math.floor(min(xx)) nh = math.ceil(max(yy)) - math.floor(min(yy)) @@ -2069,9 +2502,13 @@ def transform(x, y, matrix): matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix) w, h = nw, nh - return self.transform((w, h), AFFINE, matrix, resample, fillcolor=fillcolor) + return self.transform( + (w, h), Transform.AFFINE, matrix, resample, fillcolor=fillcolor + ) - def save(self, fp, format=None, **params): + def save( + self, fp: StrOrBytesPath | IO[bytes], format: str | None = None, **params: Any + ) -> None: """ Saves this image under the given filename. If no format is specified, the format to use is determined from the filename @@ -2088,7 +2525,7 @@ def save(self, fp, format=None, **params): implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filename (string), pathlib.Path object or file object. + :param fp: A filename (string), os.PathLike object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2101,28 +2538,31 @@ def save(self, fp, format=None, **params): may have been created, and may contain partial data. """ - filename = "" + filename: str | bytes = "" open_fp = False - if isPath(fp): - filename = fp - open_fp = True - elif isinstance(fp, Path): - filename = str(fp) + if is_path(fp): + filename = os.fspath(fp) open_fp = True - if not filename and hasattr(fp, "name") and isPath(fp.name): + elif fp == sys.stdout: + try: + fp = sys.stdout.buffer + except AttributeError: + pass + if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes - filename = fp.name + filename = os.fspath(fp.name) # may mutate self! self._ensure_mutable() save_all = params.pop("save_all", False) - self.encoderinfo = params - self.encoderconfig = () + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} + self.encoderconfig: tuple[Any, ...] = () preinit() - ext = os.path.splitext(filename)[1].lower() + filename_ext = os.path.splitext(filename)[1].lower() + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext if not format: if ext not in EXTENSION: @@ -2130,7 +2570,8 @@ def save(self, fp, format=None, **params): try: format = EXTENSION[ext] except KeyError as e: - raise ValueError(f"unknown file extension: {ext}") from e + msg = f"unknown file extension: {ext}" + raise ValueError(msg) from e if format.upper() not in SAVE: init() @@ -2139,22 +2580,38 @@ def save(self, fp, format=None, **params): else: save_handler = SAVE[format.upper()] + created = False if open_fp: + created = not os.path.exists(filename) if params.get("append", False): # Open also for reading ("+"), because TIFF save_all # writer needs to go back and edit the written data. fp = builtins.open(filename, "r+b") else: fp = builtins.open(filename, "w+b") + else: + fp = cast(IO[bytes], fp) try: save_handler(self, fp, filename) - finally: - # do what we can to clean up + except Exception: if open_fp: fp.close() + if created: + try: + os.remove(filename) + except PermissionError: + pass + raise + finally: + try: + del self.encoderinfo + except AttributeError: + pass + if open_fp: + fp.close() - def seek(self, frame): + def seek(self, frame: int) -> None: """ Seeks to the given frame in this sequence file. If you seek beyond the end of the sequence, the method raises an @@ -2173,9 +2630,10 @@ def seek(self, frame): # overridden by file handlers if frame != 0: - raise EOFError + msg = "no more images in file" + raise EOFError(msg) - def show(self, title=None, command=None): + def show(self, title: str | None = None) -> None: """ Displays this image. This method is mainly intended for debugging purposes. @@ -2185,8 +2643,8 @@ def show(self, title=None, command=None): The image is first saved to a temporary file. By default, it will be in PNG format. - On Unix, the image is then opened using the **display**, **eog** or - **xv** utility, depending on which one can be found. + On Unix, the image is then opened using the **xdg-open**, **display**, + **gm**, **eog** or **xv** utility, depending on which one can be found. On macOS, the image is opened with the native Preview application. @@ -2195,16 +2653,9 @@ def show(self, title=None, command=None): :param title: Optional title to use for the image window, where possible. """ - if command is not None: - warnings.warn( - "The command parameter is deprecated and will be removed in a future " - "release. Use a subclass of ImageShow.Viewer instead.", - DeprecationWarning, - ) - - _show(self, title=title, command=command) + _show(self, title=title) - def split(self): + def split(self) -> tuple[Image, ...]: """ Split this image into individual bands. This method returns a tuple of individual image bands from an image. For example, @@ -2220,12 +2671,10 @@ def split(self): self.load() if self.im.bands == 1: - ims = [self.copy()] - else: - ims = map(self._new, self.im.split()) - return tuple(ims) + return (self.copy(),) + return tuple(map(self._new, self.im.split())) - def getchannel(self, channel): + def getchannel(self, channel: int | str) -> Image: """ Returns an image containing a single channel of the source image. @@ -2242,11 +2691,12 @@ def getchannel(self, channel): try: channel = self.getbands().index(channel) except ValueError as e: - raise ValueError(f'The image has no channel "{channel}"') from e + msg = f'The image has no channel "{channel}"' + raise ValueError(msg) from e return self._new(self.im.getband(channel)) - def tell(self): + def tell(self) -> int: """ Returns the current frame number. See :py:meth:`~PIL.Image.Image.seek`. @@ -2257,7 +2707,12 @@ def tell(self): """ return 0 - def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): + def thumbnail( + self, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float | None = 2.0, + ) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than @@ -2271,13 +2726,14 @@ def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): apply this method to a :py:meth:`~PIL.Image.Image.copy` of the original image. - :param size: Requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). :param resample: Optional resampling filter. This can be one - of :py:data:`PIL.Image.NEAREST`, :py:data:`PIL.Image.BOX`, - :py:data:`PIL.Image.BILINEAR`, :py:data:`PIL.Image.HAMMING`, - :py:data:`PIL.Image.BICUBIC` or :py:data:`PIL.Image.LANCZOS`. - If omitted, it defaults to :py:data:`PIL.Image.BICUBIC`. - (was :py:data:`PIL.Image.NEAREST` prior to version 2.5.0). + of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If omitted, it defaults to :py:data:`Resampling.BICUBIC`. + (was :py:data:`Resampling.NEAREST` prior to version 2.5.0). See: :ref:`concept-filters`. :param reducing_gap: Apply optimization by resizing the image in two steps. First, reducing the image by integer times @@ -2296,56 +2752,71 @@ def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): :returns: None """ - x, y = map(math.floor, size) - if x >= self.width and y >= self.height: - return + provided_size = tuple(map(math.floor, size)) - def round_aspect(number, key): - return max(min(math.floor(number), math.ceil(number), key=key), 1) + def preserve_aspect_ratio() -> tuple[int, int] | None: + def round_aspect(number: float, key: Callable[[int], float]) -> int: + return max(min(math.floor(number), math.ceil(number), key=key), 1) - # preserve aspect ratio - aspect = self.width / self.height - if x / y >= aspect: - x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) - else: - y = round_aspect( - x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) - ) - size = (x, y) + x, y = provided_size + if x >= self.width and y >= self.height: + return None + + aspect = self.width / self.height + if x / y >= aspect: + x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) + else: + y = round_aspect( + x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) + ) + return x, y + + preserved_size = preserve_aspect_ratio() + if preserved_size is None: + return + final_size = preserved_size box = None if reducing_gap is not None: - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) if res is not None: box = res[1] - if self.size != size: - im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) self.im = im.im - self._size = size - self.mode = self.im.mode + self._size = final_size + self._mode = self.im.mode self.readonly = 0 - self.pyaccess = None # FIXME: the different transform methods need further explanation # instead of bloating the method docs, add a separate chapter. def transform( - self, size, method, data=None, resample=NEAREST, fill=1, fillcolor=None - ): + self, + size: tuple[int, int], + method: Transform | ImageTransformHandler | SupportsGetData, + data: Sequence[Any] | None = None, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Transforms this image. This method creates a new image with the given size, and the same mode as the original, and copies data to the new image using the given transform. - :param size: The output size. + :param size: The output size in pixels, as a 2-tuple: + (width, height). :param method: The transformation method. This is one of - :py:data:`PIL.Image.EXTENT` (cut out a rectangular subregion), - :py:data:`PIL.Image.AFFINE` (affine transform), - :py:data:`PIL.Image.PERSPECTIVE` (perspective transform), - :py:data:`PIL.Image.QUAD` (map a quadrilateral to a rectangle), or - :py:data:`PIL.Image.MESH` (map a number of source quadrilaterals + :py:data:`Transform.EXTENT` (cut out a rectangular subregion), + :py:data:`Transform.AFFINE` (affine transform), + :py:data:`Transform.PERSPECTIVE` (perspective transform), + :py:data:`Transform.QUAD` (map a quadrilateral to a rectangle), or + :py:data:`Transform.MESH` (map a number of source quadrilaterals in one operation). It may also be an :py:class:`~PIL.Image.ImageTransformHandler` @@ -2355,21 +2826,25 @@ class Example(Image.ImageTransformHandler): def transform(self, size, data, resample, fill=1): # Return result + Implementations of :py:class:`~PIL.Image.ImageTransformHandler` + for some of the :py:class:`Transform` methods are provided + in :py:mod:`~PIL.ImageTransform`. + It may also be an object with a ``method.getdata`` method that returns a tuple supplying new ``method`` and ``data`` values:: class Example: def getdata(self): - method = Image.EXTENT + method = Image.Transform.EXTENT data = (0, 0, 100, 100) return method, data :param data: Extra data to the transformation method. :param resample: Optional resampling filter. It can be one of - :py:data:`PIL.Image.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.BICUBIC` (cubic spline + :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline interpolation in a 4x4 environment). If omitted, or if the image - has mode "1" or "P", it is set to :py:data:`PIL.Image.NEAREST`. + has mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. See: :ref:`concept-filters`. :param fill: If ``method`` is an :py:class:`~PIL.Image.ImageTransformHandler` object, this is one of @@ -2379,18 +2854,11 @@ def getdata(self): :returns: An :py:class:`~PIL.Image.Image` object. """ - if self.mode == "LA": + if self.mode in ("LA", "RGBA") and resample != Resampling.NEAREST: return ( - self.convert("La") + self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) .transform(size, method, data, resample, fill, fillcolor) - .convert("LA") - ) - - if self.mode == "RGBA": - return ( - self.convert("RGBa") - .transform(size, method, data, resample, fill, fillcolor) - .convert("RGBA") + .convert(self.mode) ) if isinstance(method, ImageTransformHandler): @@ -2401,14 +2869,19 @@ def getdata(self): method, data = method.getdata() if data is None: - raise ValueError("missing method data") + msg = "missing method data" + raise ValueError(msg) im = new(self.mode, size, fillcolor) + if self.mode == "P" and self.palette: + im.palette = self.palette.copy() im.info = self.info.copy() - if method == MESH: + if method == Transform.MESH: # list of quads for box, quad in data: - im.__transformer(box, self, QUAD, quad, resample, fillcolor is None) + im.__transformer( + box, self, Transform.QUAD, quad, resample, fillcolor is None + ) else: im.__transformer( (0, 0) + size, self, method, data, resample, fillcolor is None @@ -2416,28 +2889,36 @@ def getdata(self): return im - def __transformer(self, box, image, method, data, resample=NEAREST, fill=1): + def __transformer( + self, + box: tuple[int, int, int, int], + image: Image, + method: Transform, + data: Sequence[float], + resample: int = Resampling.NEAREST, + fill: bool = True, + ) -> None: w = box[2] - box[0] h = box[3] - box[1] - if method == AFFINE: - data = data[0:6] + if method == Transform.AFFINE: + data = data[:6] - elif method == EXTENT: + elif method == Transform.EXTENT: # convert extent to an affine transform x0, y0, x1, y1 = data xs = (x1 - x0) / w ys = (y1 - y0) / h - method = AFFINE + method = Transform.AFFINE data = (xs, 0, x0, 0, ys, y0) - elif method == PERSPECTIVE: - data = data[0:8] + elif method == Transform.PERSPECTIVE: + data = data[:8] - elif method == QUAD: + elif method == Transform.QUAD: # quadrilateral warp. data specifies the four corners # given as NW, SW, SE, and NE. - nw = data[0:2] + nw = data[:2] sw = data[2:4] se = data[4:6] ne = data[6:8] @@ -2456,54 +2937,59 @@ def __transformer(self, box, image, method, data, resample=NEAREST, fill=1): ) else: - raise ValueError("unknown transformation method") - - if resample not in (NEAREST, BILINEAR, BICUBIC): - if resample in (BOX, HAMMING, LANCZOS): - message = { - BOX: "Image.BOX", - HAMMING: "Image.HAMMING", - LANCZOS: "Image.LANCZOS/Image.ANTIALIAS", - }[resample] + f" ({resample}) cannot be used." + msg = "unknown transformation method" + raise ValueError(msg) + + if resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + ): + if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS): + unusable: dict[int, str] = { + Resampling.BOX: "Image.Resampling.BOX", + Resampling.HAMMING: "Image.Resampling.HAMMING", + Resampling.LANCZOS: "Image.Resampling.LANCZOS", + } + msg = unusable[resample] + f" ({resample}) cannot be used." else: - message = f"Unknown resampling filter ({resample})." + msg = f"Unknown resampling filter ({resample})." filters = [ - "{} ({})".format(filter[1], filter[0]) + f"{filter[1]} ({filter[0]})" for filter in ( - (NEAREST, "Image.NEAREST"), - (BILINEAR, "Image.BILINEAR"), - (BICUBIC, "Image.BICUBIC"), + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) image.load() self.load() if image.mode in ("1", "P"): - resample = NEAREST + resample = Resampling.NEAREST - self.im.transform2(box, image.im, method, data, resample, fill) + self.im.transform(box, image.im, method, data, resample, fill) - def transpose(self, method): + def transpose(self, method: Transpose) -> Image: """ Transpose image (flip or rotate in 90 degree steps) - :param method: One of :py:data:`PIL.Image.FLIP_LEFT_RIGHT`, - :py:data:`PIL.Image.FLIP_TOP_BOTTOM`, :py:data:`PIL.Image.ROTATE_90`, - :py:data:`PIL.Image.ROTATE_180`, :py:data:`PIL.Image.ROTATE_270`, - :py:data:`PIL.Image.TRANSPOSE` or :py:data:`PIL.Image.TRANSVERSE`. + :param method: One of :py:data:`Transpose.FLIP_LEFT_RIGHT`, + :py:data:`Transpose.FLIP_TOP_BOTTOM`, :py:data:`Transpose.ROTATE_90`, + :py:data:`Transpose.ROTATE_180`, :py:data:`Transpose.ROTATE_270`, + :py:data:`Transpose.TRANSPOSE` or :py:data:`Transpose.TRANSVERSE`. :returns: Returns a flipped or rotated copy of this image. """ self.load() return self._new(self.im.transpose(method)) - def effect_spread(self, distance): + def effect_spread(self, distance: int) -> Image: """ Randomly spread pixels in an image. @@ -2512,20 +2998,22 @@ def effect_spread(self, distance): self.load() return self._new(self.im.effect_spread(distance)) - def toqimage(self): + def toqimage(self) -> ImageQt.ImageQt: """Returns a QImage copy of this image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqimage(self) - def toqpixmap(self): + def toqpixmap(self) -> ImageQt.QPixmap: """Returns a QPixmap copy of this image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqpixmap(self) @@ -2539,7 +3027,9 @@ class ImagePointHandler: (for use with :py:meth:`~PIL.Image.Image.point`) """ - pass + @abc.abstractmethod + def point(self, im: Image) -> Image: + pass class ImageTransformHandler: @@ -2548,7 +3038,14 @@ class ImageTransformHandler: (for use with :py:meth:`~PIL.Image.Image.transform`) """ - pass + @abc.abstractmethod + def transform( + self, + size: tuple[int, int], + image: Image, + **options: Any, + ) -> Image: + pass # -------------------------------------------------------------------- @@ -2558,31 +3055,36 @@ class ImageTransformHandler: # Debugging -def _wedge(): - """Create greyscale wedge (for debugging only)""" +def _wedge() -> Image: + """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) -def _check_size(size): +def _check_size(size: Any) -> None: """ Common check to enforce type and sanity check on size tuples :param size: Should be a 2 tuple of (width, height) - :returns: True, or raises a ValueError + :returns: None, or raises a ValueError """ if not isinstance(size, (list, tuple)): - raise ValueError("Size must be a tuple") + msg = "Size must be a list or tuple" + raise ValueError(msg) if len(size) != 2: - raise ValueError("Size must be a tuple of length 2") + msg = "Size must be a sequence of length 2" + raise ValueError(msg) if size[0] < 0 or size[1] < 0: - raise ValueError("Width and height must be >= 0") + msg = "Width and height must be >= 0" + raise ValueError(msg) - return True - -def new(mode, size, color=0): +def new( + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, +) -> Image: """ Creates a new image with the given mode and size. @@ -2592,12 +3094,15 @@ def new(mode, size, color=0): :param color: What color to use for the image. Default is black. If given, this should be a single integer or floating point value for single-band modes, and a tuple for multi-band modes (one value - per band). When creating RGB images, you can also use color + per band). When creating RGB or HSV images, you can also use color strings as supported by the ImageColor module. If the color is None, the image is not initialised. :returns: An :py:class:`~PIL.Image.Image` object. """ + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + _check_size(size) if color is None: @@ -2612,25 +3117,37 @@ def new(mode, size, color=0): color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name="raw", *args): +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. In its simplest form, this function takes three arguments (mode, size, and unpacked pixel data). - You can also use any pixel decoder supported by PIL. For more + You can also use any pixel decoder supported by PIL. For more information on available decoders, see the section - :ref:`Writing Your Own File Decoder `. + :ref:`Writing Your Own File Codec `. Note that this function decodes pixel data only, not entire images. If you have an entire image in a string, wrap it in a @@ -2647,19 +3164,27 @@ def frombytes(mode, size, data, decoder_name="raw", *args): _check_size(size) - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + im = new(mode, size) + if im.width != 0 and im.height != 0: + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] - if decoder_name == "raw" and args == (): - args = mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode - im = new(mode, size) - im.frombytes(data, decoder_name, args) + im.frombytes(data, decoder_name, decoder_args) return im -def frombuffer(mode, size, data, decoder_name="raw", *args): +def frombuffer( + mode: str, + size: tuple[int, int], + data: bytes | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -2672,11 +3197,10 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): If you have an entire image file in a string, wrap it in a :py:class:`~io.BytesIO` object, and use :py:func:`~PIL.Image.open` to load it. - In the current version, the default parameters used for the "raw" decoder - differs from that used for :py:func:`~PIL.Image.frombytes`. This is a - bug, and will probably be fixed in a future release. The current release - issues a warning if you do this; to disable the warning, you should provide - the full set of parameters. See below for details. + The default parameters used for the "raw" decoder differs from that used for + :py:func:`~PIL.Image.frombytes`. This is a bug, and will probably be fixed in a + future release. The current release issues a warning if you do this; to disable + the warning, you should provide the full set of parameters. See below for details. :param mode: The image mode. See: :ref:`concept-modes`. :param size: The image size. @@ -2704,36 +3228,71 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): if args == (): args = mode, 0, 1 if args[0] in _MAPMODES: - im = new(mode, (1, 1)) + im = new(mode, (0, 0)) im = im._new(core.map_buffer(data, size, decoder_name, 0, args)) + if mode == "P": + from . import ImagePalette + + im.palette = ImagePalette.ImagePalette("RGB", im.im.getpalette("RGB")) im.readonly = 1 return im return frombytes(mode, size, data, decoder_name, args) -def fromarray(obj, mode=None): +class SupportsArrayInterface(Protocol): + """ + An object that has an ``__array_interface__`` dictionary. + """ + + @property + def __array_interface__(self) -> dict[str, Any]: + raise NotImplementedError() + + +def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: """ Creates an image memory from an object exporting the array interface - (using the buffer protocol). + (using the buffer protocol):: + + from PIL import Image + import numpy as np + a = np.zeros((5, 5)) + im = Image.fromarray(a) If ``obj`` is not contiguous, then the ``tobytes`` method is called and :py:func:`~PIL.Image.frombuffer` is used. - If you have an image in NumPy:: + In the case of NumPy, be aware that Pillow modes do not always correspond + to NumPy dtypes. Pillow modes only offer 1-bit pixels, 8-bit pixels, + 32-bit signed integer pixels, and 32-bit floating point pixels. + + Pillow images can also be converted to arrays:: from PIL import Image import numpy as np - im = Image.open('hopper.jpg') + im = Image.open("hopper.jpg") a = np.asarray(im) - Then this can be used to convert it to a Pillow image:: - - im = Image.fromarray(a) + When converting Pillow images to arrays however, only pixel values are + transferred. This means that P and PA mode images will lose their palette. :param obj: Object with array interface - :param mode: Mode to use (will be determined from type if None) - See: :ref:`concept-modes`. + :param mode: Optional mode to use when reading ``obj``. Will be determined from + type if ``None``. + + This will not be used to convert the data after reading, but will be used to + change how the data is read:: + + from PIL import Image + import numpy as np + a = np.full((1, 1), 300) + im = Image.fromarray(a, mode="L") + im.getpixel((0, 0)) # 44 + im = Image.fromarray(a, mode="RGB") + im.getpixel((0, 0)) # (44, 1, 0) + + See: :ref:`concept-modes` for general information about modes. :returns: An image object. .. versionadded:: 1.1.6 @@ -2746,11 +3305,14 @@ def fromarray(obj, mode=None): try: typekey = (1, 1) + shape[2:], arr["typestr"] except KeyError as e: - raise TypeError("Cannot handle this data type") from e + msg = "Cannot handle this data type" + raise TypeError(msg) from e try: mode, rawmode = _fromarray_typemap[typekey] except KeyError as e: - raise TypeError("Cannot handle this data type: %s, %s" % typekey) from e + typekey_shape, typestr = typekey + msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" + raise TypeError(msg) from e else: rawmode = mode if mode in ["1", "L", "I", "P", "F"]: @@ -2760,33 +3322,39 @@ def fromarray(obj, mode=None): else: ndmax = 4 if ndim > ndmax: - raise ValueError(f"Too many dimensions: {ndim} > {ndmax}.") + msg = f"Too many dimensions: {ndim} > {ndmax}." + raise ValueError(msg) size = 1 if ndim == 1 else shape[1], shape[0] if strides is not None: if hasattr(obj, "tobytes"): obj = obj.tobytes() - else: + elif hasattr(obj, "tostring"): obj = obj.tostring() + else: + msg = "'strides' requires either tobytes() or tostring()" + raise ValueError(msg) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) -def fromqimage(im): +def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqimage(im) -def fromqpixmap(im): +def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile: """Creates an image instance from a QPixmap image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqpixmap(im) @@ -2811,24 +3379,24 @@ def fromqpixmap(im): ((1, 1, 2), "|u1"): ("LA", "LA"), ((1, 1, 3), "|u1"): ("RGB", "RGB"), ((1, 1, 4), "|u1"): ("RGBA", "RGBA"), + # shortcuts: + ((1, 1), f"{_ENDIAN}i4"): ("I", "I"), + ((1, 1), f"{_ENDIAN}f4"): ("F", "F"), } -# shortcuts -_fromarray_typemap[((1, 1), _ENDIAN + "i4")] = ("I", "I") -_fromarray_typemap[((1, 1), _ENDIAN + "f4")] = ("F", "F") - -def _decompression_bomb_check(size): +def _decompression_bomb_check(size: tuple[int, int]) -> None: if MAX_IMAGE_PIXELS is None: return - pixels = size[0] * size[1] + pixels = max(1, size[0]) * max(1, size[1]) if pixels > 2 * MAX_IMAGE_PIXELS: - raise DecompressionBombError( + msg = ( f"Image size ({pixels} pixels) exceeds limit of {2 * MAX_IMAGE_PIXELS} " "pixels, could be decompression bomb DOS attack." ) + raise DecompressionBombError(msg) if pixels > MAX_IMAGE_PIXELS: warnings.warn( @@ -2838,7 +3406,11 @@ def _decompression_bomb_check(size): ) -def open(fp, mode="r", formats=None): +def open( + fp: StrOrBytesPath | IO[bytes], + mode: Literal["r"] = "r", + formats: list[str] | tuple[str, ...] | None = None, +) -> ImageFile.ImageFile: """ Opens and identifies the given image file. @@ -2848,15 +3420,16 @@ def open(fp, mode="r", formats=None): :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filename (string), pathlib.Path object or a file object. + :param fp: A filename (string), os.PathLike object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, - and be opened in binary mode. + and be opened in binary mode. The file object will also seek to zero + before reading. :param mode: The mode. If given, this argument must be "r". :param formats: A list or tuple of formats to attempt to load the file in. This can be used to restrict the set of formats checked. Pass ``None`` to try all supported formats. You can print the set of - available formats by running ``python -m PIL`` or using + available formats by running ``python3 -m PIL`` or using the :py:func:`PIL.features.pilinfo` function. :returns: An :py:class:`~PIL.Image.Image` object. :exception FileNotFoundError: If the file cannot be found. @@ -2868,28 +3441,31 @@ def open(fp, mode="r", formats=None): """ if mode != "r": - raise ValueError(f"bad mode {repr(mode)}") + msg = f"bad mode {repr(mode)}" # type: ignore[unreachable] + raise ValueError(msg) elif isinstance(fp, io.StringIO): - raise ValueError( + msg = ( # type: ignore[unreachable] "StringIO cannot be used to open an image. " "Binary data must be used instead." ) + raise ValueError(msg) if formats is None: formats = ID elif not isinstance(formats, (list, tuple)): - raise TypeError("formats must be a list or tuple") + msg = "formats must be a list or tuple" # type: ignore[unreachable] + raise TypeError(msg) exclusive_fp = False - filename = "" - if isinstance(fp, Path): - filename = str(fp.resolve()) - elif isPath(fp): - filename = fp + filename: str | bytes = "" + if is_path(fp): + filename = os.fspath(fp) if filename: fp = builtins.open(filename, "rb") exclusive_fp = True + else: + fp = cast(IO[bytes], fp) try: fp.seek(0) @@ -2901,25 +3477,31 @@ def open(fp, mode="r", formats=None): preinit() - accept_warnings = [] + warning_messages: list[str] = [] - def _open_core(fp, filename, prefix, formats): + def _open_core( + fp: IO[bytes], + filename: str | bytes, + prefix: bytes, + formats: list[str] | tuple[str, ...], + ) -> ImageFile.ImageFile | None: for i in formats: + i = i.upper() + if i not in OPEN: + init() try: factory, accept = OPEN[i] result = not accept or accept(prefix) - if type(result) in [str, bytes]: - accept_warnings.append(result) + if isinstance(result, str): + warning_messages.append(result) elif result: fp.seek(0) im = factory(fp, filename) _decompression_bomb_check(im.size) return im - except (SyntaxError, IndexError, TypeError, struct.error): - # Leave disabled by default, spams the logs with image - # opening failures that are entirely expected. - # logger.debug("", exc_info=True) - continue + except (SyntaxError, IndexError, TypeError, struct.error) as e: + if WARN_POSSIBLE_FORMATS: + warning_messages.append(i + " opening failed. " + str(e)) except BaseException: if exclusive_fp: fp.close() @@ -2928,9 +3510,15 @@ def _open_core(fp, filename, prefix, formats): im = _open_core(fp, filename, prefix, formats) - if im is None: + if im is None and formats is ID: + checked_formats = ID.copy() if init(): - im = _open_core(fp, filename, prefix, formats) + im = _open_core( + fp, + filename, + prefix, + tuple(format for format in formats if format not in checked_formats), + ) if im: im._exclusive_fp = exclusive_fp @@ -2938,18 +3526,17 @@ def _open_core(fp, filename, prefix, formats): if exclusive_fp: fp.close() - for message in accept_warnings: + for message in warning_messages: warnings.warn(message) - raise UnidentifiedImageError( - "cannot identify image file %r" % (filename if filename else fp) - ) + msg = "cannot identify image file %r" % (filename if filename else fp) + raise UnidentifiedImageError(msg) # # Image processing. -def alpha_composite(im1, im2): +def alpha_composite(im1: Image, im2: Image) -> Image: """ Alpha composite im2 over im1. @@ -2964,10 +3551,10 @@ def alpha_composite(im1, im2): return im1._new(core.alpha_composite(im1.im, im2.im)) -def blend(im1, im2, alpha): +def blend(im1: Image, im2: Image, alpha: float) -> Image: """ Creates a new image by interpolating between two input images, using - a constant alpha.:: + a constant alpha:: out = image1 * (1.0 - alpha) + image2 * alpha @@ -2987,7 +3574,7 @@ def blend(im1, im2, alpha): return im1._new(core.blend(im1.im, im2.im, alpha)) -def composite(image1, image2, mask): +def composite(image1: Image, image2: Image, mask: Image) -> Image: """ Create composite image by blending images using a transparency mask. @@ -3004,7 +3591,7 @@ def composite(image1, image2, mask): return image -def eval(image, *args): +def eval(image: Image, *args: Callable[[int], float]) -> Image: """ Applies the function (which should take one argument) to each pixel in the given image. If the image has more than one band, the same @@ -3020,7 +3607,7 @@ def eval(image, *args): return image.point(args[0]) -def merge(mode, bands): +def merge(mode: str, bands: Sequence[Image]) -> Image: """ Merge a set of single band images into a new multiband image. @@ -3033,12 +3620,15 @@ def merge(mode, bands): """ if getmodebands(mode) != len(bands) or "*" in mode: - raise ValueError("wrong number of bands") + msg = "wrong number of bands" + raise ValueError(msg) for band in bands[1:]: if band.mode != getmodetype(mode): - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if band.size != bands[0].size: - raise ValueError("size mismatch") + msg = "size mismatch" + raise ValueError(msg) for band in bands: band.load() return bands[0]._new(core.merge(mode, *[b.im for b in bands])) @@ -3048,7 +3638,14 @@ def merge(mode, bands): # Plugin registry -def register_open(id, factory, accept=None): +def register_open( + id: str, + factory: ( + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile] + | type[ImageFile.ImageFile] + ), + accept: Callable[[bytes], bool | str] | None = None, +) -> None: """ Register an image file plugin. This function should not be used in application code. @@ -3059,14 +3656,19 @@ def register_open(id, factory, accept=None): reject images having another format. """ id = id.upper() - ID.append(id) + if id not in ID: + ID.append(id) OPEN[id] = factory, accept -def register_mime(id, mimetype): +def register_mime(id: str, mimetype: str) -> None: """ - Registers an image MIME type. This function should not be used - in application code. + Registers an image MIME type by populating ``Image.MIME``. This function + should not be used in application code. + + ``Image.MIME`` provides a mapping from image format identifiers to mime + formats, but :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` can + provide a different result for specific images. :param id: An image format identifier. :param mimetype: The image MIME type for this format. @@ -3074,7 +3676,9 @@ def register_mime(id, mimetype): MIME[id.upper()] = mimetype -def register_save(id, driver): +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3085,7 +3689,9 @@ def register_save(id, driver): SAVE[id.upper()] = driver -def register_save_all(id, driver): +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image function to save all the frames of a multiframe format. This function should not be @@ -3097,7 +3703,7 @@ def register_save_all(id, driver): SAVE_ALL[id.upper()] = driver -def register_extension(id, extension): +def register_extension(id: str, extension: str) -> None: """ Registers an image extension. This function should not be used in application code. @@ -3108,7 +3714,7 @@ def register_extension(id, extension): EXTENSION[extension.lower()] = id.upper() -def register_extensions(id, extensions): +def register_extensions(id: str, extensions: list[str]) -> None: """ Registers image extensions. This function should not be used in application code. @@ -3120,38 +3726,35 @@ def register_extensions(id, extensions): register_extension(id, extension) -def registered_extensions(): +def registered_extensions() -> dict[str, str]: """ Returns a dictionary containing all file extensions belonging to registered plugins """ - if not EXTENSION: - init() + init() return EXTENSION -def register_decoder(name, decoder): +def register_decoder(name: str, decoder: type[ImageFile.PyDecoder]) -> None: """ Registers an image decoder. This function should not be used in application code. :param name: The name of the decoder - :param decoder: A callable(mode, args) that returns an - ImageFile.PyDecoder object + :param decoder: An ImageFile.PyDecoder object .. versionadded:: 4.1.0 """ DECODERS[name] = decoder -def register_encoder(name, encoder): +def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: """ Registers an image encoder. This function should not be used in application code. :param name: The name of the encoder - :param encoder: A callable(mode, args) that returns an - ImageFile.PyEncoder object + :param encoder: An ImageFile.PyEncoder object .. versionadded:: 4.1.0 """ @@ -3162,43 +3765,32 @@ def register_encoder(name, encoder): # Simple display support. -def _show(image, **options): - options["_internal_pillow"] = True - _showxv(image, **options) - - -def _showxv(image, title=None, **options): +def _show(image: Image, **options: Any) -> None: from . import ImageShow - if "_internal_pillow" in options: - del options["_internal_pillow"] - else: - warnings.warn( - "_showxv is deprecated and will be removed in a future release. " - "Use Image.show instead.", - DeprecationWarning, - ) - ImageShow.show(image, title, **options) + ImageShow.show(image, **options) # -------------------------------------------------------------------- # Effects -def effect_mandelbrot(size, extent, quality): +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int +) -> Image: """ Generate a Mandelbrot set covering the given extent. :param size: The requested size in pixels, as a 2-tuple: (width, height). :param extent: The extent to cover, as a 4-tuple: - (x0, y0, x1, y2). + (x0, y0, x1, y1). :param quality: Quality. """ return Image()._new(core.effect_mandelbrot(size, extent, quality)) -def effect_noise(size, sigma): +def effect_noise(size: tuple[int, int], sigma: float) -> Image: """ Generate Gaussian noise centered around 128. @@ -3209,7 +3801,7 @@ def effect_noise(size, sigma): return Image()._new(core.effect_noise(size, sigma)) -def linear_gradient(mode): +def linear_gradient(mode: str) -> Image: """ Generate 256x256 linear gradient from black to white, top to bottom. @@ -3218,7 +3810,7 @@ def linear_gradient(mode): return Image()._new(core.linear_gradient(mode)) -def radial_gradient(mode): +def radial_gradient(mode: str) -> Image: """ Generate 256x256 radial gradient from black to white, centre to edge. @@ -3231,19 +3823,18 @@ def radial_gradient(mode): # Resources -def _apply_env_variables(env=None): - if env is None: - env = os.environ +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ for var_name, setter in [ ("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCKS_MAX", core.set_blocks_max), ]: - if var_name not in env: + if var_name not in env_dict: continue - var = env[var_name].lower() + var = env_dict[var_name].lower() units = 1 for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: @@ -3252,13 +3843,13 @@ def _apply_env_variables(env=None): var = var[: -len(postfix)] try: - var = int(var) * units + var_int = int(var) * units except ValueError: warnings.warn(f"{var_name} is not int") continue try: - setter(var) + setter(var_int) except ValueError as e: warnings.warn(f"{var_name}: {e}") @@ -3267,16 +3858,58 @@ def _apply_env_variables(env=None): atexit.register(core.clear_cache) -class Exif(MutableMapping): - endian = "<" +if TYPE_CHECKING: + _ExifBase = MutableMapping[int, Any] +else: + _ExifBase = MutableMapping - def __init__(self): - self._data = {} - self._ifds = {} - self._info = None - self._loaded_exif = None - def _fixup(self, value): +class Exif(_ExifBase): + """ + This class provides read and write access to EXIF image data:: + + from PIL import Image + im = Image.open("exif.png") + exif = im.getexif() # Returns an instance of this class + + Information can be read and written, iterated over or deleted:: + + print(exif[274]) # 1 + exif[274] = 2 + for k, v in exif.items(): + print("Tag", k, "Value", v) # Tag 274 Value 2 + del exif[274] + + To access information beyond IFD0, :py:meth:`~PIL.Image.Exif.get_ifd` + returns a dictionary:: + + from PIL import ExifTags + im = Image.open("exif_gps.jpg") + exif = im.getexif() + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + print(gps_ifd) + + Other IFDs include ``ExifTags.IFD.Exif``, ``ExifTags.IFD.MakerNote``, + ``ExifTags.IFD.Interop`` and ``ExifTags.IFD.IFD1``. + + :py:mod:`~PIL.ExifTags` also has enum classes to provide names for data:: + + print(exif[ExifTags.Base.Software]) # PIL + print(gps_ifd[ExifTags.GPS.GPSDateStamp]) # 1999:99:99 99:99:99 + """ + + endian: str | None = None + bigtiff = False + _loaded = False + + def __init__(self) -> None: + self._data: dict[int, Any] = {} + self._hidden_data: dict[int, Any] = {} + self._ifds: dict[int, dict[int, Any]] = {} + self._info: TiffImagePlugin.ImageFileDirectory_v2 | None = None + self._loaded_exif: bytes | None = None + + def _fixup(self, value: Any) -> Any: try: if len(value) == 1 and isinstance(value, tuple): return value[0] @@ -3284,26 +3917,39 @@ def _fixup(self, value): pass return value - def _fixup_dict(self, src_dict): + def _fixup_dict(self, src_dict: dict[int, Any]) -> dict[int, Any]: # Helper function # returns a dict with any single item tuples/lists as individual values return {k: self._fixup(v) for k, v in src_dict.items()} - def _get_ifd_dict(self, tag): + def _get_ifd_dict( + self, offset: int, group: int | None = None + ) -> dict[int, Any] | None: try: # an offset pointer to the location of the nested embedded IFD. # It should be a long, but may be corrupted. - self.fp.seek(self[tag]) + self.fp.seek(offset) except (KeyError, TypeError): - pass + return None else: from . import TiffImagePlugin - info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + info = TiffImagePlugin.ImageFileDirectory_v2(self.head, group=group) info.load(self.fp) - return self._fixup_dict(info) + return self._fixup_dict(dict(info)) - def load(self, data): + def _get_head(self) -> bytes: + version = b"\x2B" if self.bigtiff else b"\x2A" + if self.endian == "<": + head = b"II" + version + b"\x00" + o32le(8) + else: + head = b"MM\x00" + version + o32be(8) + if self.bigtiff: + head += o32le(8) if self.endian == "<" else o32be(8) + head += b"\x00\x00\x00\x00" + return head + + def load(self, data: bytes) -> None: # Extract EXIF information. This is highly experimental, # and is likely to be replaced with something better in a future # version. @@ -3314,14 +3960,15 @@ def load(self, data): return self._loaded_exif = data self._data.clear() + self._hidden_data.clear() self._ifds.clear() - self._info = None + while data and data.startswith(b"Exif\x00\x00"): + data = data[6:] if not data: + self._info = None return - if data.startswith(b"Exif\x00\x00"): - data = data[6:] - self.fp = io.BytesIO(data) + self.fp: IO[bytes] = io.BytesIO(data) self.head = self.fp.read(8) # process dictionary from . import TiffImagePlugin @@ -3331,145 +3978,219 @@ def load(self, data): self.fp.seek(self._info.next) self._info.load(self.fp) - # get EXIF extension - ifd = self._get_ifd_dict(0x8769) - if ifd: - self._data.update(ifd) - self._ifds[0x8769] = ifd + def load_from_fp(self, fp: IO[bytes], offset: int | None = None) -> None: + self._loaded_exif = None + self._data.clear() + self._hidden_data.clear() + self._ifds.clear() - def tobytes(self, offset=8): + # process dictionary from . import TiffImagePlugin - if self.endian == "<": - head = b"II\x2A\x00\x08\x00\x00\x00" + self.fp = fp + if offset is not None: + self.head = self._get_head() else: - head = b"MM\x00\x2A\x00\x00\x00\x08" + self.head = self.fp.read(8) + self._info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + if self.endian is None: + self.endian = self._info._endian + if offset is None: + offset = self._info.next + self.fp.tell() + self.fp.seek(offset) + self._info.load(self.fp) + + def _get_merged_dict(self) -> dict[int, Any]: + merged_dict = dict(self) + + # get EXIF extension + if ExifTags.IFD.Exif in self: + ifd = self._get_ifd_dict(self[ExifTags.IFD.Exif], ExifTags.IFD.Exif) + if ifd: + merged_dict.update(ifd) + + # GPS + if ExifTags.IFD.GPSInfo in self: + merged_dict[ExifTags.IFD.GPSInfo] = self._get_ifd_dict( + self[ExifTags.IFD.GPSInfo], ExifTags.IFD.GPSInfo + ) + + return merged_dict + + def tobytes(self, offset: int = 8) -> bytes: + from . import TiffImagePlugin + + head = self._get_head() ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head) + for tag, ifd_dict in self._ifds.items(): + if tag not in self: + ifd[tag] = ifd_dict for tag, value in self.items(): + if tag in [ + ExifTags.IFD.Exif, + ExifTags.IFD.GPSInfo, + ] and not isinstance(value, dict): + value = self.get_ifd(tag) + if ( + tag == ExifTags.IFD.Exif + and ExifTags.IFD.Interop in value + and not isinstance(value[ExifTags.IFD.Interop], dict) + ): + value = value.copy() + value[ExifTags.IFD.Interop] = self.get_ifd(ExifTags.IFD.Interop) ifd[tag] = value return b"Exif\x00\x00" + head + ifd.tobytes(offset) - def get_ifd(self, tag): - if tag not in self._ifds and tag in self: - if tag in [0x8825, 0xA005]: - # gpsinfo, interop - self._ifds[tag] = self._get_ifd_dict(tag) - elif tag == 0x927C: # makernote - from .TiffImagePlugin import ImageFileDirectory_v2 - - if self[0x927C][:8] == b"FUJIFILM": - exif_data = self[0x927C] - ifd_offset = i32le(exif_data[8:12]) - ifd_data = exif_data[ifd_offset:] - - makernote = {} - for i in range(0, struct.unpack(" 4: - (offset,) = struct.unpack(" dict[int, Any]: + if tag not in self._ifds: + if tag == ExifTags.IFD.IFD1: + if self._info is not None and self._info.next != 0: + ifd = self._get_ifd_dict(self._info.next) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: + offset = self._hidden_data.get(tag, self.get(tag)) + if offset is not None: + ifd = self._get_ifd_dict(offset, tag) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.MakerNote]: + if ExifTags.IFD.Exif not in self._ifds: + self.get_ifd(ExifTags.IFD.Exif) + tag_data = self._ifds[ExifTags.IFD.Exif][tag] + if tag == ExifTags.IFD.MakerNote: + from .TiffImagePlugin import ImageFileDirectory_v2 + + if tag_data[:8] == b"FUJIFILM": + ifd_offset = i32le(tag_data, 8) + ifd_data = tag_data[ifd_offset:] + + makernote = {} + for i in range(0, struct.unpack("H", ifd_data[:2])[0]): - ifd_tag, typ, count, data = struct.unpack( - ">HHL4s", ifd_data[i * 12 + 2 : (i + 1) * 12 + 2] - ) - if ifd_tag == 0x1101: - # CameraInfo - (offset,) = struct.unpack(">L", data) - self.fp.seek(offset) - - camerainfo = {"ModelID": self.fp.read(4)} - - self.fp.read(4) - # Seconds since 2000 - camerainfo["TimeStamp"] = i32le(self.fp.read(12)) - - self.fp.read(4) - camerainfo["InternalSerialNumber"] = self.fp.read(4) - - self.fp.read(12) - parallax = self.fp.read(4) - handler = ImageFileDirectory_v2._load_dispatch[ - TiffTags.FLOAT - ][1] - camerainfo["Parallax"] = handler( - ImageFileDirectory_v2(), parallax, False + try: + ( + unit_size, + handler, + ) = ImageFileDirectory_v2._load_dispatch[typ] + except KeyError: + continue + size = count * unit_size + if size > 4: + (offset,) = struct.unpack("H", tag_data[:2])[0]): + ifd_tag, typ, count, data = struct.unpack( + ">HHL4s", tag_data[i * 12 + 2 : (i + 1) * 12 + 2] + ) + if ifd_tag == 0x1101: + # CameraInfo + (offset,) = struct.unpack(">L", data) + self.fp.seek(offset) + + camerainfo: dict[str, int | bytes] = { + "ModelID": self.fp.read(4) + } + + self.fp.read(4) + # Seconds since 2000 + camerainfo["TimeStamp"] = i32le(self.fp.read(12)) + + self.fp.read(4) + camerainfo["InternalSerialNumber"] = self.fp.read(4) + + self.fp.read(12) + parallax = self.fp.read(4) + handler = ImageFileDirectory_v2._load_dispatch[ + TiffTags.FLOAT + ][1] + camerainfo["Parallax"] = handler( + ImageFileDirectory_v2(), parallax, False + )[0] + + self.fp.read(4) + camerainfo["Category"] = self.fp.read(2) + + makernote = {0x1101: camerainfo} + self._ifds[tag] = makernote + else: + # Interop + ifd = self._get_ifd_dict(tag_data, tag) + if ifd is not None: + self._ifds[tag] = ifd + ifd = self._ifds.setdefault(tag, {}) + if tag == ExifTags.IFD.Exif and self._hidden_data: + ifd = { + k: v + for (k, v) in ifd.items() + if k not in (ExifTags.IFD.Interop, ExifTags.IFD.MakerNote) + } + return ifd + + def hide_offsets(self) -> None: + for tag in (ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo): + if tag in self: + self._hidden_data[tag] = self[tag] + del self[tag] + + def __str__(self) -> str: if self._info is not None: # Load all keys into self._data - for tag in self._info.keys(): + for tag in self._info: self[tag] return str(self._data) - def __len__(self): + def __len__(self) -> int: keys = set(self._data) if self._info is not None: keys.update(self._info) return len(keys) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if self._info is not None and tag not in self._data and tag in self._info: self._data[tag] = self._fixup(self._info[tag]) - if tag == 0x8825: - self._data[tag] = self.get_ifd(tag) del self._info[tag] return self._data[tag] - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._data or (self._info is not None and tag in self._info) - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: if self._info is not None and tag in self._info: del self._info[tag] self._data[tag] = value - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: if self._info is not None and tag in self._info: del self._info[tag] else: del self._data[tag] - def __iter__(self): + def __iter__(self) -> Iterator[int]: keys = set(self._data) if self._info is not None: keys.update(self._info) diff --git a/dependencies/OSX/PIL/ImageChops.py b/dependencies/OSX/PIL/ImageChops.py index 61d3a29..29a5c99 100644 --- a/dependencies/OSX/PIL/ImageChops.py +++ b/dependencies/OSX/PIL/ImageChops.py @@ -15,11 +15,13 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + from . import Image -def constant(image, value): - """Fill a channel with a given grey level. +def constant(image: Image.Image, value: int) -> Image.Image: + """Fill a channel with a given gray level. :rtype: :py:class:`~PIL.Image.Image` """ @@ -27,7 +29,7 @@ def constant(image, value): return Image.new("L", image.size, value) -def duplicate(image): +def duplicate(image: Image.Image) -> Image.Image: """Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`. :rtype: :py:class:`~PIL.Image.Image` @@ -36,11 +38,9 @@ def duplicate(image): return image.copy() -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ - Invert an image (channel). - - .. code-block:: python + Invert an image (channel). :: out = MAX - image @@ -51,12 +51,10 @@ def invert(image): return image._new(image.im.chop_invert()) -def lighter(image1, image2): +def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the lighter values. - - .. code-block:: python + the lighter values. :: out = max(image1, image2) @@ -68,12 +66,10 @@ def lighter(image1, image2): return image1._new(image1.im.chop_lighter(image2.im)) -def darker(image1, image2): +def darker(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the darker values. - - .. code-block:: python + the darker values. :: out = min(image1, image2) @@ -85,12 +81,10 @@ def darker(image1, image2): return image1._new(image1.im.chop_darker(image2.im)) -def difference(image1, image2): +def difference(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Returns the absolute value of the pixel-by-pixel difference between the two - images. - - .. code-block:: python + images. :: out = abs(image1 - image2) @@ -102,14 +96,12 @@ def difference(image1, image2): return image1._new(image1.im.chop_difference(image2.im)) -def multiply(image1, image2): +def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other. If you multiply an image with a solid black image, the result is black. If - you multiply with a solid white image, the image is unaffected. - - .. code-block:: python + you multiply with a solid white image, the image is unaffected. :: out = image1 * image2 / MAX @@ -121,11 +113,9 @@ def multiply(image1, image2): return image1._new(image1.im.chop_multiply(image2.im)) -def screen(image1, image2): +def screen(image1: Image.Image, image2: Image.Image) -> Image.Image: """ - Superimposes two inverted images on top of each other. - - .. code-block:: python + Superimposes two inverted images on top of each other. :: out = MAX - ((MAX - image1) * (MAX - image2) / MAX) @@ -137,7 +127,7 @@ def screen(image1, image2): return image1._new(image1.im.chop_screen(image2.im)) -def soft_light(image1, image2): +def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Soft Light algorithm @@ -149,7 +139,7 @@ def soft_light(image1, image2): return image1._new(image1.im.chop_soft_light(image2.im)) -def hard_light(image1, image2): +def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Hard Light algorithm @@ -161,7 +151,7 @@ def hard_light(image1, image2): return image1._new(image1.im.chop_hard_light(image2.im)) -def overlay(image1, image2): +def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Overlay algorithm @@ -173,12 +163,12 @@ def overlay(image1, image2): return image1._new(image1.im.chop_overlay(image2.im)) -def add(image1, image2, scale=1.0, offset=0): +def add( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Adds two images, dividing the result by scale and adding the - offset. If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + offset. If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 + image2) / scale + offset) @@ -190,12 +180,12 @@ def add(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_add(image2.im, scale, offset)) -def subtract(image1, image2, scale=1.0, offset=0): +def subtract( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Subtracts two images, dividing the result by scale and adding the offset. - If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 - image2) / scale + offset) @@ -207,10 +197,8 @@ def subtract(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_subtract(image2.im, scale, offset)) -def add_modulo(image1, image2): - """Add two images, without clipping the result. - - .. code-block:: python +def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Add two images, without clipping the result. :: out = ((image1 + image2) % MAX) @@ -222,10 +210,8 @@ def add_modulo(image1, image2): return image1._new(image1.im.chop_add_modulo(image2.im)) -def subtract_modulo(image1, image2): - """Subtract two images, without clipping the result. - - .. code-block:: python +def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Subtract two images, without clipping the result. :: out = ((image1 - image2) % MAX) @@ -237,15 +223,13 @@ def subtract_modulo(image1, image2): return image1._new(image1.im.chop_subtract_modulo(image2.im)) -def logical_and(image1, image2): +def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical AND between two images. Both of the images must have mode "1". If you would like to perform a logical AND on an image with a mode other than "1", try :py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask - as the second image. - - .. code-block:: python + as the second image. :: out = ((image1 and image2) % MAX) @@ -257,12 +241,10 @@ def logical_and(image1, image2): return image1._new(image1.im.chop_and(image2.im)) -def logical_or(image1, image2): +def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical OR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((image1 or image2) % MAX) @@ -274,12 +256,10 @@ def logical_or(image1, image2): return image1._new(image1.im.chop_or(image2.im)) -def logical_xor(image1, image2): +def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical XOR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((bool(image1) != bool(image2)) % MAX) @@ -291,7 +271,7 @@ def logical_xor(image1, image2): return image1._new(image1.im.chop_xor(image2.im)) -def blend(image1, image2, alpha): +def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image: """Blend images using constant transparency weight. Alias for :py:func:`PIL.Image.blend`. @@ -301,7 +281,9 @@ def blend(image1, image2, alpha): return Image.blend(image1, image2, alpha) -def composite(image1, image2, mask): +def composite( + image1: Image.Image, image2: Image.Image, mask: Image.Image +) -> Image.Image: """Create composite using transparency mask. Alias for :py:func:`PIL.Image.composite`. @@ -311,11 +293,12 @@ def composite(image1, image2, mask): return Image.composite(image1, image2, mask) -def offset(image, xoffset, yoffset=None): +def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image: """Returns a copy of the image where data has been offset by the given distances. Data wraps around the edges. If ``yoffset`` is omitted, it is assumed to be equal to ``xoffset``. + :param image: Input image. :param xoffset: The horizontal distance. :param yoffset: The vertical distance. If omitted, both distances are set to the same value. diff --git a/dependencies/OSX/PIL/ImageCms.py b/dependencies/OSX/PIL/ImageCms.py index 8c4740d..fdfbee7 100644 --- a/dependencies/OSX/PIL/ImageCms.py +++ b/dependencies/OSX/PIL/ImageCms.py @@ -4,6 +4,9 @@ # Optional color management support, based on Kevin Cazabon's PyCMS # library. +# Originally released under LGPL. Graciously donated to PIL in +# March 2009, for distribution under the standard PIL license + # History: # 2009-03-08 fl Added to PIL. @@ -14,30 +17,41 @@ # See the README file for information on usage and redistribution. See # below for the original description. +from __future__ import annotations +import operator import sys +from enum import IntEnum, IntFlag +from functools import reduce +from typing import Any, Literal, SupportsFloat, SupportsInt, Union -from PIL import Image +from . import Image, __version__ +from ._deprecate import deprecate +from ._typing import SupportsRead try: - from PIL import _imagingcms + from . import _imagingcms as core + + _CmsProfileCompatible = Union[ + str, SupportsRead[bytes], core.CmsProfile, "ImageCmsProfile" + ] except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. - from ._util import deferred_error + from ._util import DeferredError - _imagingcms = deferred_error(ex) + core = DeferredError.new(ex) -DESCRIPTION = """ +_DESCRIPTION = """ pyCMS a Python / PIL interface to the littleCMS ICC Color Management System Copyright (C) 2002-2003 Kevin Cazabon kevin@cazabon.com - http://www.cazabon.com + https://www.cazabon.com - pyCMS home page: http://www.cazabon.com/pyCMS - littleCMS home page: http://www.littlecms.com + pyCMS home page: https://www.cazabon.com/pyCMS + littleCMS home page: https://www.littlecms.com (littleCMS is Copyright (C) 1998-2001 Marti Maria) Originally released under LGPL. Graciously donated to PIL in @@ -91,28 +105,110 @@ """ -VERSION = "1.0.0 pil" +_VERSION = "1.0.0 pil" + + +def __getattr__(name: str) -> Any: + if name == "DESCRIPTION": + deprecate("PIL.ImageCms.DESCRIPTION", 12) + return _DESCRIPTION + elif name == "VERSION": + deprecate("PIL.ImageCms.VERSION", 12) + return _VERSION + elif name == "FLAGS": + deprecate("PIL.ImageCms.FLAGS", 12, "PIL.ImageCms.Flags") + return _FLAGS + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) + # --------------------------------------------------------------------. -core = _imagingcms # # intent/direction values -INTENT_PERCEPTUAL = 0 -INTENT_RELATIVE_COLORIMETRIC = 1 -INTENT_SATURATION = 2 -INTENT_ABSOLUTE_COLORIMETRIC = 3 -DIRECTION_INPUT = 0 -DIRECTION_OUTPUT = 1 -DIRECTION_PROOF = 2 +class Intent(IntEnum): + PERCEPTUAL = 0 + RELATIVE_COLORIMETRIC = 1 + SATURATION = 2 + ABSOLUTE_COLORIMETRIC = 3 + + +class Direction(IntEnum): + INPUT = 0 + OUTPUT = 1 + PROOF = 2 + # # flags -FLAGS = { + +class Flags(IntFlag): + """Flags and documentation are taken from ``lcms2.h``.""" + + NONE = 0 + NOCACHE = 0x0040 + """Inhibit 1-pixel cache""" + NOOPTIMIZE = 0x0100 + """Inhibit optimizations""" + NULLTRANSFORM = 0x0200 + """Don't transform anyway""" + GAMUTCHECK = 0x1000 + """Out of Gamut alarm""" + SOFTPROOFING = 0x4000 + """Do softproofing""" + BLACKPOINTCOMPENSATION = 0x2000 + NOWHITEONWHITEFIXUP = 0x0004 + """Don't fix scum dot""" + HIGHRESPRECALC = 0x0400 + """Use more memory to give better accuracy""" + LOWRESPRECALC = 0x0800 + """Use less memory to minimize resources""" + # this should be 8BITS_DEVICELINK, but that is not a valid name in Python: + USE_8BITS_DEVICELINK = 0x0008 + """Create 8 bits devicelinks""" + GUESSDEVICECLASS = 0x0020 + """Guess device class (for ``transform2devicelink``)""" + KEEP_SEQUENCE = 0x0080 + """Keep profile sequence for devicelink creation""" + FORCE_CLUT = 0x0002 + """Force CLUT optimization""" + CLUT_POST_LINEARIZATION = 0x0001 + """create postlinearization tables if possible""" + CLUT_PRE_LINEARIZATION = 0x0010 + """create prelinearization tables if possible""" + NONEGATIVES = 0x8000 + """Prevent negative numbers in floating point transforms""" + COPY_ALPHA = 0x04000000 + """Alpha channels are copied on ``cmsDoTransform()``""" + NODEFAULTRESOURCEDEF = 0x01000000 + + _GRIDPOINTS_1 = 1 << 16 + _GRIDPOINTS_2 = 2 << 16 + _GRIDPOINTS_4 = 4 << 16 + _GRIDPOINTS_8 = 8 << 16 + _GRIDPOINTS_16 = 16 << 16 + _GRIDPOINTS_32 = 32 << 16 + _GRIDPOINTS_64 = 64 << 16 + _GRIDPOINTS_128 = 128 << 16 + + @staticmethod + def GRIDPOINTS(n: int) -> Flags: + """ + Fine-tune control over number of gridpoints + + :param n: :py:class:`int` in range ``0 <= n <= 255`` + """ + return Flags.NONE | ((n & 0xFF) << 16) + + +_MAX_FLAG = reduce(operator.or_, Flags) + + +_FLAGS = { "MATRIXINPUT": 1, "MATRIXOUTPUT": 2, "MATRIXONLY": (1 | 2), @@ -132,14 +228,9 @@ "SOFTPROOFING": 16384, # Do softproofing "PRESERVEBLACK": 32768, # Black preservation "NODEFAULTRESOURCEDEF": 16777216, # CRD special - "GRIDPOINTS": lambda n: ((n) & 0xFF) << 16, # Gridpoints + "GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints } -_MAX_FLAG = 0 -for flag in FLAGS.values(): - if isinstance(flag, int): - _MAX_FLAG = _MAX_FLAG | flag - # --------------------------------------------------------------------. # Experimental PIL-level API @@ -150,7 +241,7 @@ class ImageCmsProfile: - def __init__(self, profile): + def __init__(self, profile: str | SupportsRead[bytes] | core.CmsProfile) -> None: """ :param profile: Either a string representing a filename, a file like object containing a profile or a @@ -170,22 +261,19 @@ def __init__(self, profile): self._set(core.profile_open(profile), profile) elif hasattr(profile, "read"): self._set(core.profile_frombytes(profile.read())) - elif isinstance(profile, _imagingcms.CmsProfile): + elif isinstance(profile, core.CmsProfile): self._set(profile) else: - raise TypeError("Invalid type for Profile") + msg = "Invalid type for Profile" # type: ignore[unreachable] + raise TypeError(msg) - def _set(self, profile, filename=None): + def _set(self, profile: core.CmsProfile, filename: str | None = None) -> None: self.profile = profile self.filename = filename - if profile: - self.product_name = None # profile.product_name - self.product_info = None # profile.product_info - else: - self.product_name = None - self.product_info = None + self.product_name = None # profile.product_name + self.product_info = None # profile.product_info - def tobytes(self): + def tobytes(self) -> bytes: """ Returns the profile in a format suitable for embedding in saved images. @@ -197,7 +285,6 @@ def tobytes(self): class ImageCmsTransform(Image.ImagePointHandler): - """ Transform. This can be used with the procedural API, or with the standard :py:func:`~PIL.Image.Image.point` method. @@ -207,15 +294,40 @@ class ImageCmsTransform(Image.ImagePointHandler): def __init__( self, - input, - output, - input_mode, - output_mode, - intent=INTENT_PERCEPTUAL, - proof=None, - proof_intent=INTENT_ABSOLUTE_COLORIMETRIC, - flags=0, + input: ImageCmsProfile, + output: ImageCmsProfile, + input_mode: str, + output_mode: str, + intent: Intent = Intent.PERCEPTUAL, + proof: ImageCmsProfile | None = None, + proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.NONE, ): + supported_modes = ( + "RGB", + "RGBA", + "RGBX", + "CMYK", + "I;16", + "I;16L", + "I;16B", + "YCbCr", + "LAB", + "L", + "1", + ) + for mode in (input_mode, output_mode): + if mode not in supported_modes: + deprecate( + mode, + 12, + { + "L;16": "I;16 or I;16L", + "L:16B": "I;16B", + "YCCA": "YCbCr", + "YCC": "YCbCr", + }.get(mode), + ) if proof is None: self.transform = core.buildTransform( input.profile, output.profile, input_mode, output_mode, intent, flags @@ -237,27 +349,26 @@ def __init__( self.output_profile = output - def point(self, im): + def point(self, im: Image.Image) -> Image.Image: return self.apply(im) - def apply(self, im, imOut=None): - im.load() + def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: if imOut is None: imOut = Image.new(self.output_mode, im.size, None) - self.transform.apply(im.im.id, imOut.im.id) + self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut - def apply_in_place(self, im): - im.load() + def apply_in_place(self, im: Image.Image) -> Image.Image: if im.mode != self.output_mode: - raise ValueError("mode mismatch") # wrong output mode - self.transform.apply(im.im.id, im.im.id) + msg = "mode mismatch" + raise ValueError(msg) # wrong output mode + self.transform.apply(im.getim(), im.getim()) im.info["icc_profile"] = self.output_profile.tobytes() return im -def get_display_profile(handle=None): +def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | None: """ (experimental) Fetches the profile for the current display device. @@ -267,12 +378,12 @@ def get_display_profile(handle=None): if sys.platform != "win32": return None - from PIL import ImageWin + from . import ImageWin # type: ignore[unused-ignore, unreachable] if isinstance(handle, ImageWin.HDC): - profile = core.get_display_profile_win32(handle, 1) + profile = core.get_display_profile_win32(int(handle), 1) else: - profile = core.get_display_profile_win32(handle or 0) + profile = core.get_display_profile_win32(int(handle or 0)) if profile is None: return None return ImageCmsProfile(profile) @@ -284,7 +395,6 @@ def get_display_profile(handle=None): class PyCMSError(Exception): - """(pyCMS) Exception class. This is used for all errors in the pyCMS API.""" @@ -292,14 +402,14 @@ class PyCMSError(Exception): def profileToProfile( - im, - inputProfile, - outputProfile, - renderingIntent=INTENT_PERCEPTUAL, - outputMode=None, - inPlace=False, - flags=0, -): + im: Image.Image, + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + renderingIntent: Intent = Intent.PERCEPTUAL, + outputMode: str | None = None, + inPlace: bool = False, + flags: Flags = Flags.NONE, +) -> Image.Image | None: """ (pyCMS) Applies an ICC transformation to a given image, mapping from ``inputProfile`` to ``outputProfile``. @@ -331,10 +441,10 @@ def profileToProfile( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -356,10 +466,12 @@ def profileToProfile( outputMode = im.mode if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -385,7 +497,9 @@ def profileToProfile( return imOut -def getOpenProfile(profileFilename): +def getOpenProfile( + profileFilename: str | SupportsRead[bytes] | core.CmsProfile, +) -> ImageCmsProfile: """ (pyCMS) Opens an ICC profile file. @@ -408,13 +522,13 @@ def getOpenProfile(profileFilename): def buildTransform( - inputProfile, - outputProfile, - inMode, - outMode, - renderingIntent=INTENT_PERCEPTUAL, - flags=0, -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + flags: Flags = Flags.NONE, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``. Use applyTransform to apply the transform to a given @@ -458,10 +572,10 @@ def buildTransform( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -471,10 +585,12 @@ def buildTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -489,15 +605,15 @@ def buildTransform( def buildProofTransform( - inputProfile, - outputProfile, - proofProfile, - inMode, - outMode, - renderingIntent=INTENT_PERCEPTUAL, - proofRenderingIntent=INTENT_ABSOLUTE_COLORIMETRIC, - flags=FLAGS["SOFTPROOFING"], -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + proofProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.SOFTPROOFING, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``, but tries to simulate the result that would be @@ -550,20 +666,20 @@ def buildProofTransform( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the input->proof (simulated) transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. :param proofRenderingIntent: Integer (0-3) specifying the rendering intent you wish to use for proof->output transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -573,10 +689,12 @@ def buildProofTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -603,16 +721,18 @@ def buildProofTransform( buildProofTransformFromOpenProfiles = buildProofTransform -def applyTransform(im, transform, inPlace=False): +def applyTransform( + im: Image.Image, transform: ImageCmsTransform, inPlace: bool = False +) -> Image.Image | None: """ (pyCMS) Applies a transform to a given image. - If ``im.mode != transform.inMode``, a :exc:`PyCMSError` is raised. + If ``im.mode != transform.input_mode``, a :exc:`PyCMSError` is raised. - If ``inPlace`` is ``True`` and ``transform.inMode != transform.outMode``, a + If ``inPlace`` is ``True`` and ``transform.input_mode != transform.output_mode``, a :exc:`PyCMSError` is raised. - If ``im.mode``, ``transform.inMode`` or ``transform.outMode`` is not + If ``im.mode``, ``transform.input_mode`` or ``transform.output_mode`` is not supported by pyCMSdll or the profiles you used for the transform, a :exc:`PyCMSError` is raised. @@ -626,13 +746,13 @@ def applyTransform(im, transform, inPlace=False): If you want to modify im in-place instead of receiving a new image as the return value, set ``inPlace`` to ``True``. This can only be done if - ``transform.inMode`` and ``transform.outMode`` are the same, because we can't - change the mode in-place (the buffer sizes for some modes are + ``transform.input_mode`` and ``transform.output_mode`` are the same, because we + can't change the mode in-place (the buffer sizes for some modes are different). The default behavior is to return a new :py:class:`~PIL.Image.Image` - object of the same dimensions in mode ``transform.outMode``. + object of the same dimensions in mode ``transform.output_mode``. - :param im: An :py:class:`~PIL.Image.Image` object, and im.mode must be the same - as the ``inMode`` supported by the transform. + :param im: An :py:class:`~PIL.Image.Image` object, and ``im.mode`` must be the same + as the ``input_mode`` supported by the transform. :param transform: A valid CmsTransform class object :param inPlace: Bool. If ``True``, ``im`` is modified in place and ``None`` is returned, if ``False``, a new :py:class:`~PIL.Image.Image` object with the @@ -656,7 +776,9 @@ def applyTransform(im, transform, inPlace=False): return imOut -def createProfile(colorSpace, colorTemp=-1): +def createProfile( + colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0 +) -> core.CmsProfile: """ (pyCMS) Creates a profile. @@ -678,7 +800,7 @@ def createProfile(colorSpace, colorTemp=-1): :param colorSpace: String, the color space of the profile you wish to create. Currently only "LAB", "XYZ", and "sRGB" are supported. - :param colorTemp: Positive integer for the white point for the profile, in + :param colorTemp: Positive number for the white point for the profile, in degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 illuminant if omitted (5000k). colorTemp is ONLY applied to LAB profiles, and is ignored for XYZ and sRGB. @@ -687,17 +809,17 @@ def createProfile(colorSpace, colorTemp=-1): """ if colorSpace not in ["LAB", "XYZ", "sRGB"]: - raise PyCMSError( + msg = ( f"Color space not supported for on-the-fly profile creation ({colorSpace})" ) + raise PyCMSError(msg) if colorSpace == "LAB": try: colorTemp = float(colorTemp) except (TypeError, ValueError) as e: - raise PyCMSError( - f'Color temperature must be numeric, "{colorTemp}" not valid' - ) from e + msg = f'Color temperature must be numeric, "{colorTemp}" not valid' + raise PyCMSError(msg) from e try: return core.createProfile(colorSpace, colorTemp) @@ -705,7 +827,7 @@ def createProfile(colorSpace, colorTemp=-1): raise PyCMSError(v) from v -def getProfileName(profile): +def getProfileName(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product name for the given profile. @@ -739,15 +861,15 @@ def getProfileName(profile): if not (model or manufacturer): return (profile.profile.profile_description or "") + "\n" - if not manufacturer or len(model) > 30: - return model + "\n" + if not manufacturer or (model and len(model) > 30): + return f"{model}\n" return f"{model} - {manufacturer}\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v -def getProfileInfo(profile): +def getProfileInfo(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product information for the given profile. @@ -777,17 +899,14 @@ def getProfileInfo(profile): # info was description \r\n\r\n copyright \r\n\r\n K007 tag \r\n\r\n whitepoint description = profile.profile.profile_description cpright = profile.profile.copyright - arr = [] - for elt in (description, cpright): - if elt: - arr.append(elt) - return "\r\n\r\n".join(arr) + "\r\n\r\n" + elements = [element for element in (description, cpright) if element] + return "\r\n\r\n".join(elements) + "\r\n\r\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v -def getProfileCopyright(profile): +def getProfileCopyright(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the copyright for the given profile. @@ -815,7 +934,7 @@ def getProfileCopyright(profile): raise PyCMSError(v) from v -def getProfileManufacturer(profile): +def getProfileManufacturer(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the manufacturer for the given profile. @@ -843,7 +962,7 @@ def getProfileManufacturer(profile): raise PyCMSError(v) from v -def getProfileModel(profile): +def getProfileModel(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the model for the given profile. @@ -872,7 +991,7 @@ def getProfileModel(profile): raise PyCMSError(v) from v -def getProfileDescription(profile): +def getProfileDescription(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the description for the given profile. @@ -901,7 +1020,7 @@ def getProfileDescription(profile): raise PyCMSError(v) from v -def getDefaultIntent(profile): +def getDefaultIntent(profile: _CmsProfileCompatible) -> int: """ (pyCMS) Gets the default intent name for the given profile. @@ -922,10 +1041,10 @@ def getDefaultIntent(profile): :returns: Integer 0-3 specifying the default rendering intent for this profile. - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -940,7 +1059,9 @@ def getDefaultIntent(profile): raise PyCMSError(v) from v -def isIntentSupported(profile, intent, direction): +def isIntentSupported( + profile: _CmsProfileCompatible, intent: Intent, direction: Direction +) -> Literal[-1, 1]: """ (pyCMS) Checks if a given intent is supported. @@ -960,19 +1081,19 @@ def isIntentSupported(profile, intent, direction): :param intent: Integer (0-3) specifying the rendering intent you wish to use with this profile - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. :param direction: Integer specifying if the profile is to be used for input, output, or proof - INPUT = 0 (or use ImageCms.DIRECTION_INPUT) - OUTPUT = 1 (or use ImageCms.DIRECTION_OUTPUT) - PROOF = 2 (or use ImageCms.DIRECTION_PROOF) + INPUT = 0 (or use ImageCms.Direction.INPUT) + OUTPUT = 1 (or use ImageCms.Direction.OUTPUT) + PROOF = 2 (or use ImageCms.Direction.PROOF) :returns: 1 if the intent/direction are supported, -1 if they are not. :exception PyCMSError: @@ -991,9 +1112,14 @@ def isIntentSupported(profile, intent, direction): raise PyCMSError(v) from v -def versions(): +def versions() -> tuple[str, str | None, str, str]: """ (pyCMS) Fetches versions. """ - return (VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__) + deprecate( + "PIL.ImageCms.versions()", + 12, + '(PIL.features.version("littlecms2"), sys.version, PIL.__version__)', + ) + return _VERSION, core.littlecms_version, sys.version.split()[0], __version__ diff --git a/dependencies/OSX/PIL/ImageColor.py b/dependencies/OSX/PIL/ImageColor.py index 9091174..9a15a8e 100644 --- a/dependencies/OSX/PIL/ImageColor.py +++ b/dependencies/OSX/PIL/ImageColor.py @@ -16,34 +16,42 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from functools import lru_cache from . import Image -def getrgb(color): +@lru_cache +def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]: """ - Convert a color string to an RGB tuple. If the string cannot be parsed, - this function raises a :py:exc:`ValueError` exception. + Convert a color string to an RGB or RGBA tuple. If the string cannot be + parsed, this function raises a :py:exc:`ValueError` exception. .. versionadded:: 1.1.4 :param color: A color string :return: ``(red, green, blue[, alpha])`` """ + if len(color) > 100: + msg = "color specifier is too long" + raise ValueError(msg) color = color.lower() rgb = colormap.get(color, None) if rgb: if isinstance(rgb, tuple): return rgb - colormap[color] = rgb = getrgb(rgb) - return rgb + rgb_tuple = getrgb(rgb) + assert len(rgb_tuple) == 3 + colormap[color] = rgb_tuple + return rgb_tuple # check for known string formats if re.match("#[a-f0-9]{3}$", color): - return (int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)) + return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16) if re.match("#[a-f0-9]{4}$", color): return ( @@ -54,7 +62,7 @@ def getrgb(color): ) if re.match("#[a-f0-9]{6}$", color): - return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) + return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16) if re.match("#[a-f0-9]{8}$", color): return ( @@ -66,7 +74,7 @@ def getrgb(color): m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + return int(m.group(1)), int(m.group(2)), int(m.group(3)) m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color) if m: @@ -82,15 +90,15 @@ def getrgb(color): if m: from colorsys import hls_to_rgb - rgb = hls_to_rgb( + rgb_floats = hls_to_rgb( float(m.group(1)) / 360.0, float(m.group(3)) / 100.0, float(m.group(2)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match( @@ -99,53 +107,65 @@ def getrgb(color): if m: from colorsys import hsv_to_rgb - rgb = hsv_to_rgb( + rgb_floats = hsv_to_rgb( float(m.group(1)) / 360.0, float(m.group(2)) / 100.0, float(m.group(3)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))) - raise ValueError(f"unknown color specifier: {repr(color)}") + return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) + msg = f"unknown color specifier: {repr(color)}" + raise ValueError(msg) -def getcolor(color, mode): +@lru_cache +def getcolor(color: str, mode: str) -> int | tuple[int, ...]: """ - Same as :py:func:`~PIL.ImageColor.getrgb`, but converts the RGB value to a - greyscale value if the mode is not color or a palette image. If the string - cannot be parsed, this function raises a :py:exc:`ValueError` exception. + Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if + ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is + not color or a palette image, converts the RGB value to a grayscale value. + If the string cannot be parsed, this function raises a :py:exc:`ValueError` + exception. .. versionadded:: 1.1.4 :param color: A color string - :return: ``(graylevel [, alpha]) or (red, green, blue[, alpha])`` + :param mode: Convert result to this mode + :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])`` """ # same as getrgb, but converts the result to the given mode - color, alpha = getrgb(color), 255 - if len(color) == 4: - color, alpha = color[0:3], color[3] + rgb, alpha = getrgb(color), 255 + if len(rgb) == 4: + alpha = rgb[3] + rgb = rgb[:3] - if Image.getmodebase(mode) == "L": - r, g, b = color + if mode == "HSV": + from colorsys import rgb_to_hsv + + r, g, b = rgb + h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) + return int(h * 255), int(s * 255), int(v * 255) + elif Image.getmodebase(mode) == "L": + r, g, b = rgb # ITU-R Recommendation 601-2 for nonlinear RGB # scaled to 24 bits to match the convert's implementation. - color = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 - if mode[-1] == "A": - return (color, alpha) - else: + graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 if mode[-1] == "A": - return color + (alpha,) - return color + return graylevel, alpha + return graylevel + elif mode[-1] == "A": + return rgb + (alpha,) + return rgb -colormap = { +colormap: dict[str, str | tuple[int, int, int]] = { # X11 colour table from https://drafts.csswg.org/css-color-4/, with # gray/grey spelling issues fixed. This is a superset of HTML 4.0 # colour names used in CSS 1. diff --git a/dependencies/OSX/PIL/ImageDraw.py b/dependencies/OSX/PIL/ImageDraw.py index b823be9..d8e4c0c 100644 --- a/dependencies/OSX/PIL/ImageDraw.py +++ b/dependencies/OSX/PIL/ImageDraw.py @@ -29,11 +29,29 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import math -import numbers +import struct +from collections.abc import Sequence +from types import ModuleType +from typing import TYPE_CHECKING, Any, AnyStr, Callable, Union, cast from . import Image, ImageColor +from ._deprecate import deprecate +from ._typing import Coords + +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] | None +try: + Outline = Image.core.outline +except AttributeError: + Outline = None + +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + +_Ink = Union[float, tuple[int, ...], str] """ A simple 2D drawing interface for PIL images. @@ -44,7 +62,11 @@ class ImageDraw: - def __init__(self, im, mode=None): + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None + + def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ Create a drawing instance. @@ -65,11 +87,13 @@ def __init__(self, im, mode=None): if mode == "RGBA" and im.mode == "RGB": blend = 1 else: - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if mode == "P": self.palette = im.palette else: self.palette = None + self._image = im self.im = im.im self.draw = Image.core.draw(self.im, blend) self.mode = mode @@ -82,13 +106,27 @@ def __init__(self, im, mode=None): self.fontmode = "1" else: self.fontmode = "L" # aliasing is okay for other modes - self.fill = 0 - self.font = None + self.fill = False - def getfont(self): + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: """ Get the current default font. + To set the default font for this ImageDraw instance:: + + from PIL import ImageDraw, ImageFont + draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + To set the default font for all future ImageDraw instances:: + + from PIL import ImageDraw, ImageFont + ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + If the current default font is ``None``, + it is initialized with ``ImageFont.load_default()``. + :returns: An image font.""" if not self.font: # FIXME: should add a font repository @@ -97,34 +135,57 @@ def getfont(self): self.font = ImageFont.load_default() return self.font - def _getink(self, ink, fill=None): + def _getfont( + self, font_size: float | None + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: + if font_size is not None: + from . import ImageFont + + return ImageFont.load_default(font_size) + else: + return self.getfont() + + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None if ink is None and fill is None: if self.fill: - fill = self.ink + result_fill = self.ink else: - ink = self.ink + result_ink = self.ink else: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) - if self.palette and not isinstance(ink, numbers.Number): - ink = self.palette.getcolor(ink) - ink = self.draw.draw_ink(ink) + if self.palette and isinstance(ink, tuple): + ink = self.palette.getcolor(ink, self._image) + result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) - if self.palette and not isinstance(fill, numbers.Number): - fill = self.palette.getcolor(fill) - fill = self.draw.draw_ink(fill) - return ink, fill + if self.palette and isinstance(fill, tuple): + fill = self.palette.getcolor(fill, self._image) + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill - def arc(self, xy, start, end, fill=None, width=1): + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy, bitmap, fill=None): + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -133,53 +194,94 @@ def bitmap(self, xy, bitmap, fill=None): if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy, start, end, fill=None, outline=None, width=1): + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a chord.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_chord(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy, fill=None, outline=None, width=1): + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an ellipse.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_ellipse(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy, fill=None, width=0, joint=None): + def circle( + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a circle given center coordinates and a radius.""" + ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) + self.ellipse(ellipse_xy, fill, outline, width) + + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - if not isinstance(xy[0], (list, tuple)): - xy = [tuple(xy[i : i + 2]) for i in range(0, len(xy), 2)] - for i in range(1, len(xy) - 1): - point = xy[i] + points: Sequence[Sequence[float]] + if isinstance(xy[0], (list, tuple)): + points = cast(Sequence[Sequence[float]], xy) + else: + points = [ + cast(Sequence[float], tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] + for i in range(1, len(points) - 1): + point = points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 - for start, end in ((xy[i - 1], point), (point, xy[i + 1])) + for start, end in ( + (points[i - 1], point), + (point, points[i + 1]), + ) ] if angles[0] == angles[1]: # This is a straight line, so no joint is required continue - def coord_at_angle(coord, angle): + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, ...]: x, y = coord angle -= 90 distance = width / 2 - 1 return tuple( - [ - p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d)) - for p, p_d in ( - (x, distance * math.cos(math.radians(angle))), - (y, distance * math.sin(math.radians(angle))), - ) - ] + p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d)) + for p, p_d in ( + (x, distance * math.cos(math.radians(angle))), + (y, distance * math.sin(math.radians(angle))), + ) ) flipped = ( @@ -198,94 +300,309 @@ def coord_at_angle(coord, angle): if width > 8: # Cover potential gaps between the line and the joint if flipped: - gapCoords = [ + gap_coords = [ coord_at_angle(point, angles[0] + 90), point, coord_at_angle(point, angles[1] + 90), ] else: - gapCoords = [ + gap_coords = [ coord_at_angle(point, angles[0] - 90), point, coord_at_angle(point, angles[1] - 90), ] - self.line(gapCoords, fill, width=3) + self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None): + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: """(Experimental) Draw a shape.""" shape.close() - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_outline(shape, fill, 1) - if ink is not None and ink != fill: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: self.draw.draw_outline(shape, ink, 0) - def pieslice(self, xy, start, end, fill=None, outline=None, width=1): + def pieslice( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a pieslice.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_pieslice(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy, fill=None): + def point(self, xy: Coords, fill: _Ink | None = None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy, fill=None, outline=None): + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a polygon.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_polygon(xy, fill, 1) - if ink is not None and ink != fill: - self.draw.draw_polygon(xy, ink, 0) + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + if width == 1: + self.draw.draw_polygon(xy, ink, 0, width) + elif self.im is not None: + # To avoid expanding the polygon outwards, + # use the fill as a mask + mask = Image.new("1", self.im.size) + mask_ink = self._getink(1)[0] + + fill_im = mask.copy() + draw = Draw(fill_im) + draw.draw.draw_polygon(xy, mask_ink, 1) + + ink_im = mask.copy() + draw = Draw(ink_im) + width = width * 2 - 1 + draw.draw.draw_polygon(xy, mask_ink, 0, width) + + mask.paste(ink_im, mask=fill_im) + + im = Image.new(self.mode, self.im.size) + draw = Draw(im) + draw.draw.draw_polygon(xy, ink, 0, width) + self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None - ): + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) - self.polygon(xy, fill, outline) + self.polygon(xy, fill, outline, width) - def rectangle(self, xy, fill=None, outline=None, width=1): + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a rectangle.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_rectangle(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) - def _multiline_check(self, text): - """Draw text.""" + def rounded_rectangle( + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, + ) -> None: + """Draw a rounded rectangle.""" + if isinstance(xy[0], (list, tuple)): + (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) + else: + x0, y0, x1, y1 = cast(Sequence[float], xy) + if x1 < x0: + msg = "x1 must be greater than or equal to x0" + raise ValueError(msg) + if y1 < y0: + msg = "y1 must be greater than or equal to y0" + raise ValueError(msg) + if corners is None: + corners = (True, True, True, True) + + d = radius * 2 + + x0 = round(x0) + y0 = round(y0) + x1 = round(x1) + y1 = round(y1) + full_x, full_y = False, False + if all(corners): + full_x = d >= x1 - x0 - 1 + if full_x: + # The two left and two right corners are joined + d = x1 - x0 + full_y = d >= y1 - y0 - 1 + if full_y: + # The two top and two bottom corners are joined + d = y1 - y0 + if full_x and full_y: + # If all corners are joined, that is a circle + return self.ellipse(xy, fill, outline, width) + + if d == 0 or not any(corners): + # If the corners have no curve, + # or there are no corners, + # that is a rectangle + return self.rectangle(xy, fill, outline, width) + + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) + + def draw_corners(pieslice: bool) -> None: + parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] + if full_x: + # Draw top and bottom halves + parts = ( + ((x0, y0, x0 + d, y0 + d), 180, 360), + ((x0, y1 - d, x0 + d, y1), 0, 180), + ) + elif full_y: + # Draw left and right halves + parts = ( + ((x0, y0, x0 + d, y0 + d), 90, 270), + ((x1 - d, y0, x1, y0 + d), 270, 90), + ) + else: + # Draw four separate corners + parts = tuple( + part + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) + ) + if corners[i] + ) + for part in parts: + if pieslice: + self.draw.draw_pieslice(*(part + (fill_ink, 1))) + else: + self.draw.draw_arc(*(part + (ink, width))) + + if fill_ink is not None: + draw_corners(True) + + if full_x: + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) + elif x1 - r - 1 > x0 + r + 1: + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) + if not full_x and not full_y: + left = [x0, y0, x0 + r, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, fill_ink, 1) + + right = [x1 - r, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + draw_corners(False) + + if not full_x: + top = [x0, y0, x1, y0 + width - 1] + if corners[0]: + top[0] += r + 1 + if corners[1]: + top[2] -= r + 1 + self.draw.draw_rectangle(top, ink, 1) + + bottom = [x0, y1 - width + 1, x1, y1] + if corners[3]: + bottom[0] += r + 1 + if corners[2]: + bottom[2] -= r + 1 + self.draw.draw_rectangle(bottom, ink, 1) + if not full_y: + left = [x0, y0, x0 + width - 1, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, ink, 1) + + right = [x1 - width + 1, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, ink, 1) + + def _multiline_check(self, text: AnyStr) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" + def _multiline_split(self, text: AnyStr) -> list[AnyStr]: + return text.split("\n" if isinstance(text, str) else b"\n") - return text.split(split_character) + def _multiline_spacing( + self, + font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, + spacing: float, + stroke_width: float, + ) -> float: + return ( + self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] + + stroke_width + + spacing + ) def text( self, - xy, - text, - fill=None, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - stroke_fill=None, - embedded_color=False, - *args, - **kwargs, - ): + xy: tuple[float, float], + text: AnyStr, + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *args: Any, + **kwargs: Any, + ) -> None: + """Draw text.""" + if embedded_color and self.mode not in ("RGB", "RGBA"): + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(kwargs.get("font_size")) + if self._multiline_check(text): return self.multiline_text( xy, @@ -303,25 +620,23 @@ def text( embedded_color, ) - if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") - - if font is None: - font = self.getfont() - - def getink(fill): - ink, fill = self._getink(fill) + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) if ink is None: - return fill + assert fill_ink is not None + return fill_ink return ink - def draw_text(ink, stroke_width=0, stroke_offset=None): + def draw_text(ink: int, stroke_width: float = 0) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" - coord = xy + coord = [] + for i in range(2): + coord.append(int(xy[i])) + start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) try: - mask, offset = font.getmask2( + mask, offset = font.getmask2( # type: ignore[union-attr,misc] text, mode, direction=direction, @@ -330,13 +645,14 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): stroke_width=stroke_width, anchor=anchor, ink=ink, + start=start, *args, **kwargs, ) - coord = coord[0] + offset[0], coord[1] + offset[1] + coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: - mask = font.getmask( + mask = font.getmask( # type: ignore[misc] text, mode, direction, @@ -345,20 +661,23 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): stroke_width, anchor, ink, + start=start, *args, **kwargs, ) except TypeError: mask = font.getmask(text) - if stroke_offset: - coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1] if mode == "RGBA": # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A # extract mask and set text alpha color, mask = mask, mask.getband(3) - color.fillband(3, (ink >> 24) & 0xFF) - coord2 = coord[0] + mask.size[0], coord[1] + mask.size[1] - self.im.paste(color, coord + coord2, mask) + ink_alpha = struct.pack("i", ink)[3] + color.fillband(3, ink_alpha) + x, y = coord + if self.im is not None: + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) else: self.draw.draw_bitmap(coord, mask, ink) @@ -380,36 +699,47 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): def multiline_text( self, - xy, - text, - fill=None, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - stroke_fill=None, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> None: if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) + line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: line_width = self.textlength( line, font, direction=direction, features=features, language=language @@ -441,7 +771,8 @@ def multiline_text( elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) self.text( (left, top), @@ -458,93 +789,63 @@ def multiline_text( ) top += line_spacing - def textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - """Get the size of a given string, in pixels.""" - if self._multiline_check(text): - return self.multiline_textsize( - text, font, spacing, direction, features, language, stroke_width - ) - - if font is None: - font = self.getfont() - return font.getsize(text, direction, features, language, stroke_width) - - def multiline_textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - max_width = 0 - lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) - for line in lines: - line_width, line_height = self.textsize( - line, font, spacing, direction, features, language, stroke_width - ) - max_width = max(max_width, line_width) - return max_width, len(lines) * line_spacing - spacing - def textlength( self, - text, - font=None, - direction=None, - features=None, - language=None, - embedded_color=False, - ): + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): - raise ValueError("can't measure length of multiline text") + msg = "can't measure length of multiline text" + raise ValueError(msg) if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) if font is None: - font = self.getfont() + font = self._getfont(font_size) mode = "RGBA" if embedded_color else self.fontmode - try: - return font.getlength(text, mode, direction, features, language) - except AttributeError: - size = self.textsize( - text, font, direction=direction, features=features, language=language - ) - if direction == "ttb": - return size[1] - return size[0] + return font.getlength(text, mode, direction, features, language) def textbbox( self, - xy, - text, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: """Get the bounding box of a given string, in pixels.""" if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) if self._multiline_check(text): return self.multiline_textbbox( @@ -561,8 +862,6 @@ def textbbox( embedded_color, ) - if font is None: - font = self.getfont() mode = "RGBA" if embedded_color else self.fontmode bbox = font.getbbox( text, mode, direction, features, language, stroke_width, anchor @@ -571,34 +870,45 @@ def textbbox( def multiline_textbbox( self, - xy, - text, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) + line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: line_width = self.textlength( line, @@ -617,7 +927,7 @@ def multiline_textbbox( elif anchor[1] == "d": top -= (len(lines) - 1) * line_spacing - bbox = None + bbox: tuple[float, float, float, float] | None = None for idx, line in enumerate(lines): left = xy[0] @@ -637,7 +947,8 @@ def multiline_textbbox( elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) bbox_line = self.textbbox( (left, top), @@ -667,7 +978,7 @@ def multiline_textbbox( return bbox -def Draw(im, mode=None): +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -679,45 +990,38 @@ def Draw(im, mode=None): defaults to the mode of the image. """ try: - return im.getdraw(mode) + return getattr(im, "getdraw")(mode) except AttributeError: return ImageDraw(im, mode) -# experimental access to the outline API -try: - Outline = Image.core.outline -except AttributeError: - Outline = None - - -def getdraw(im=None, hints=None): +def getdraw( + im: Image.Image | None = None, hints: list[str] | None = None +) -> tuple[ImageDraw2.Draw | None, ModuleType]: """ - (Experimental) A more advanced 2D drawing interface for PIL images, - based on the WCK interface. - :param im: The image to draw in. - :param hints: An optional list of hints. + :param hints: An optional list of hints. Deprecated. :returns: A (drawing context, drawing resource factory) tuple. """ - # FIXME: this needs more work! - # FIXME: come up with a better 'hints' scheme. - handler = None - if not hints or "nicest" in hints: - try: - from . import _imagingagg as handler - except ImportError: - pass - if handler is None: - from . import ImageDraw2 as handler - if im: - im = handler.Draw(im) - return im, handler - - -def floodfill(image, xy, value, border=None, thresh=0): + if hints is not None: + deprecate("'hints' parameter", 12) + from . import ImageDraw2 + + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 + + +def floodfill( + image: Image.Image, + xy: tuple[int, int], + value: float | tuple[int, ...], + border: float | tuple[int, ...] | None = None, + thresh: float = 0, +) -> None: """ - (experimental) Fills a bounded region with a given color. + .. warning:: This method is experimental. + + Fills a bounded region with a given color. :param image: Target image. :param xy: Seed position (a 2-item coordinate tuple). See @@ -735,6 +1039,7 @@ def floodfill(image, xy, value, border=None, thresh=0): # based on an implementation by Eric S. Raymond # amended by yo1995 @20180806 pixel = image.load() + assert pixel is not None x, y = xy try: background = pixel[x, y] @@ -749,8 +1054,8 @@ def floodfill(image, xy, value, border=None, thresh=0): full_edge = set() while edge: new_edge = set() - for (x, y) in edge: # 4 adjacent method - for (s, t) in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + for x, y in edge: # 4 adjacent method + for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): # If already processed, or if a coordinate is negative, skip if (s, t) in full_edge or s < 0 or t < 0: continue @@ -763,7 +1068,7 @@ def floodfill(image, xy, value, border=None, thresh=0): if border is None: fill = _color_diff(p, background) <= thresh else: - fill = p != value and p != border + fill = p not in (value, border) if fill: pixel[s, t] = value new_edge.add((s, t)) @@ -771,11 +1076,13 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): +def _compute_regular_polygon_vertices( + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float +) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. - :param bounding_circle: The bounding circle is a tuple defined + :param bounding_circle: The bounding circle is a sequence defined by a point and radius. The polygon is inscribed in this circle. (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) :param n_sides: Number of sides @@ -813,41 +1120,54 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - raise TypeError("n_sides should be an int") + msg = "n_sides should be an int" # type: ignore[unreachable] + raise TypeError(msg) if n_sides < 3: - raise ValueError("n_sides should be an int > 2") + msg = "n_sides should be an int > 2" + raise ValueError(msg) # 1.2 Check `bounding_circle` has an appropriate value if not isinstance(bounding_circle, (list, tuple)): - raise TypeError("bounding_circle should be a tuple") + msg = "bounding_circle should be a sequence" + raise TypeError(msg) if len(bounding_circle) == 3: - *centroid, polygon_radius = bounding_circle - elif len(bounding_circle) == 2: - centroid, polygon_radius = bounding_circle + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(list[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(list[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) else: - raise ValueError( + msg = ( "bounding_circle should contain 2D coordinates " "and a radius (e.g. (x, y, r) or ((x, y), r) )" ) - - if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - raise ValueError("bounding_circle should only contain numeric data") - - if not len(centroid) == 2: - raise ValueError( - "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - ) + raise ValueError(msg) if polygon_radius <= 0: - raise ValueError("bounding_circle radius should be > 0") + msg = "bounding_circle radius should be > 0" + raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - raise ValueError("rotation should be an int or float") + msg = "rotation should be an int or float" # type: ignore[unreachable] + raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point, degrees, centroid): + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -863,11 +1183,11 @@ def _apply_rotation(point, degrees, centroid): ), ) - def _compute_polygon_vertex(centroid, polygon_radius, angle): + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: start_point = [polygon_radius, 0] - return _apply_rotation(start_point, angle, centroid) + return _apply_rotation(start_point, angle) - def _get_angles(n_sides, rotation): + def _get_angles(n_sides: int, rotation: float) -> list[float]: angles = [] degrees = 360 / n_sides # Start with the bottom left polygon vertex @@ -883,16 +1203,16 @@ def _get_angles(n_sides, rotation): angles = _get_angles(n_sides, rotation) # 4. Compute Vertices - return [ - _compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles - ] + return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2): +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: """ Uses 1-norm distance to calculate difference between two values. """ - if isinstance(color2, tuple): - return sum([abs(color1[i] - color2[i]) for i in range(0, len(color2))]) - else: - return abs(color1 - color2) + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(0, len(second))) diff --git a/dependencies/OSX/PIL/ImageDraw2.py b/dependencies/OSX/PIL/ImageDraw2.py index 1f63110..3d68658 100644 --- a/dependencies/OSX/PIL/ImageDraw2.py +++ b/dependencies/OSX/PIL/ImageDraw2.py @@ -22,15 +22,18 @@ .. seealso:: :py:mod:`PIL.ImageDraw` """ +from __future__ import annotations +from typing import Any, AnyStr, BinaryIO from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath +from ._typing import Coords, StrOrBytesPath class Pen: """Stores an outline color and width.""" - def __init__(self, color, width=1, opacity=255): + def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) self.width = width @@ -38,14 +41,16 @@ def __init__(self, color, width=1, opacity=255): class Brush: """Stores a fill color""" - def __init__(self, color, opacity=255): + def __init__(self, color: str, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) class Font: """Stores a TrueType font and color""" - def __init__(self, color, file, size=12): + def __init__( + self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12 + ) -> None: # FIXME: add support for bitmap fonts self.color = ImageColor.getrgb(color) self.font = ImageFont.truetype(file, size) @@ -56,17 +61,32 @@ class Draw: (Experimental) WCK-style drawing interface """ - def __init__(self, image, size=None, color=None): - if not hasattr(image, "im"): + def __init__( + self, + image: Image.Image | str, + size: tuple[int, int] | list[int] | None = None, + color: float | tuple[float, ...] | str | None = None, + ) -> None: + if isinstance(image, str): + if size is None: + msg = "If image argument is mode string, size must be a list or tuple" + raise ValueError(msg) image = Image.new(image, size, color) self.draw = ImageDraw.Draw(image) self.image = image - self.transform = None + self.transform: tuple[float, float, float, float, float, float] | None = None - def flush(self): + def flush(self) -> Image.Image: return self.image - def render(self, op, xy, pen, brush=None): + def render( + self, + op: str, + xy: Coords, + pen: Pen | Brush | None, + brush: Brush | Pen | None = None, + **kwargs: Any, + ) -> None: # handle color arguments outline = fill = None width = 1 @@ -82,63 +102,89 @@ def render(self, op, xy, pen, brush=None): fill = pen.color # handle transformation if self.transform: - xy = ImagePath.Path(xy) - xy.transform(self.transform) + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path # render the item - if op == "line": - self.draw.line(xy, fill=outline, width=width) + if op in ("arc", "line"): + kwargs.setdefault("fill", outline) else: - getattr(self.draw, op)(xy, fill=fill, outline=outline) + kwargs.setdefault("fill", fill) + kwargs.setdefault("outline", outline) + if op == "line": + kwargs.setdefault("width", width) + getattr(self.draw, op)(xy, **kwargs) - def settransform(self, offset): + def settransform(self, offset: tuple[float, float]) -> None: """Sets a transformation offset.""" (xoffset, yoffset) = offset self.transform = (1, 0, xoffset, 0, 1, yoffset) - def arc(self, xy, start, end, *options): + def arc( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Draws an arc (a portion of a circle outline) between the start and end angles, inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc` """ - self.render("arc", xy, start, end, *options) + self.render("arc", xy, pen, *options, start=start, end=end) - def chord(self, xy, start, end, *options): + def chord( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points with a straight line. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord` """ - self.render("chord", xy, start, end, *options) + self.render("chord", xy, pen, *options, start=start, end=end) - def ellipse(self, xy, *options): + def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws an ellipse inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse` """ - self.render("ellipse", xy, *options) + self.render("ellipse", xy, pen, *options) - def line(self, xy, *options): + def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a line between the coordinates in the ``xy`` list. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line` """ - self.render("line", xy, *options) + self.render("line", xy, pen, *options) - def pieslice(self, xy, start, end, *options): + def pieslice( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as arc, but also draws straight lines between the end points and the center of the bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice` """ - self.render("pieslice", xy, start, end, *options) + self.render("pieslice", xy, pen, *options, start=start, end=end) - def polygon(self, xy, *options): + def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a polygon. @@ -149,31 +195,49 @@ def polygon(self, xy, *options): .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon` """ - self.render("polygon", xy, *options) + self.render("polygon", xy, pen, *options) - def rectangle(self, xy, *options): + def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a rectangle. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle` """ - self.render("rectangle", xy, *options) + self.render("rectangle", xy, pen, *options) - def text(self, xy, text, font): + def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None: """ Draws the string at the given position. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text` """ if self.transform: - xy = ImagePath.Path(xy) - xy.transform(self.transform) + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path self.draw.text(xy, text, font=font.font, fill=font.color) - def textsize(self, text, font): + def textbbox( + self, xy: tuple[float, float], text: AnyStr, font: Font + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of given text. + + :return: ``(left, top, right, bottom)`` bounding box + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox` + """ + if self.transform: + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path + return self.draw.textbbox(xy, text, font=font.font) + + def textlength(self, text: AnyStr, font: Font) -> float: """ - Return the size of the given string, in pixels. + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. - .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textsize` + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength` """ - return self.draw.textsize(text, font=font.font) + return self.draw.textlength(text, font=font.font) diff --git a/dependencies/OSX/PIL/ImageEnhance.py b/dependencies/OSX/PIL/ImageEnhance.py index 3b79d5c..0e7e6dd 100644 --- a/dependencies/OSX/PIL/ImageEnhance.py +++ b/dependencies/OSX/PIL/ImageEnhance.py @@ -17,12 +17,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFilter, ImageStat class _Enhance: - def enhance(self, factor): + image: Image.Image + degenerate: Image.Image + + def enhance(self, factor: float) -> Image.Image: """ Returns an enhanced image. @@ -45,13 +49,15 @@ class Color(_Enhance): the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.intermediate_mode = "L" if "A" in image.getbands(): self.intermediate_mode = "LA" - self.degenerate = image.convert(self.intermediate_mode).convert(image.mode) + if self.intermediate_mode != image.mode: + image = image.convert(self.intermediate_mode).convert(image.mode) + self.degenerate = image class Contrast(_Enhance): @@ -59,16 +65,20 @@ class Contrast(_Enhance): This class can be used to control the contrast of an image, similar to the contrast control on a TV set. An enhancement factor of 0.0 - gives a solid grey image. A factor of 1.0 gives the original image. + gives a solid gray image. A factor of 1.0 gives the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image - mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) - self.degenerate = Image.new("L", image.size, mean).convert(image.mode) + if image.mode != "L": + image = image.convert("L") + mean = int(ImageStat.Stat(image).mean[0] + 0.5) + self.degenerate = Image.new("L", image.size, mean) + if self.degenerate.mode != self.image.mode: + self.degenerate = self.degenerate.convert(self.image.mode) - if "A" in image.getbands(): - self.degenerate.putalpha(image.getchannel("A")) + if "A" in self.image.getbands(): + self.degenerate.putalpha(self.image.getchannel("A")) class Brightness(_Enhance): @@ -79,7 +89,7 @@ class Brightness(_Enhance): original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = Image.new(image.mode, image.size, 0) @@ -95,7 +105,7 @@ class Sharpness(_Enhance): original image, and a factor of 2.0 gives a sharpened image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = image.filter(ImageFilter.SMOOTH) diff --git a/dependencies/OSX/PIL/ImageFile.py b/dependencies/OSX/PIL/ImageFile.py index 49633ef..5d0f87a 100644 --- a/dependencies/OSX/PIL/ImageFile.py +++ b/dependencies/OSX/PIL/ImageFile.py @@ -26,14 +26,22 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import abc import io +import itertools +import os import struct import sys -import warnings +from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast from . import Image -from ._util import isPath +from ._deprecate import deprecate +from ._util import is_path + +if TYPE_CHECKING: + from ._typing import StrOrBytesPath MAXBLOCK = 65536 @@ -49,7 +57,11 @@ -8: "bad configuration", -9: "out of memory error", } -"""Dict of known error codes returned from :meth:`.PyDecoder.decode`.""" +""" +Dict of known error codes returned from :meth:`.PyDecoder.decode`, +:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and +:meth:`.PyEncoder.encode_to_file`. +""" # @@ -57,30 +69,39 @@ # Helpers -def raise_oserror(error): +def _get_oserror(error: int, *, encoder: bool) -> OSError: try: - message = Image.core.getcodecstatus(error) + msg = Image.core.getcodecstatus(error) except AttributeError: - message = ERRORS.get(error) - if not message: - message = f"decoder error {error}" - raise OSError(message + " when reading image file") - - -def raise_ioerror(error): - warnings.warn( - "raise_ioerror is deprecated and will be removed in a future release. " - "Use raise_oserror instead.", - DeprecationWarning, + msg = ERRORS.get(error) + if not msg: + msg = f"{'encoder' if encoder else 'decoder'} error {error}" + msg += f" when {'writing' if encoder else 'reading'} image file" + return OSError(msg) + + +def raise_oserror(error: int) -> OSError: + deprecate( + "raise_oserror", + 12, + action="It is only useful for translating error codes returned by a codec's " + "decode() method, which ImageFile already does automatically.", ) - return raise_oserror(error) + raise _get_oserror(error, encoder=False) -def _tilesort(t): +def _tilesort(t: _Tile) -> int: # sort on offset return t[2] +class _Tile(NamedTuple): + codec_name: str + extents: tuple[int, int, int, int] | None + offset: int = 0 + args: tuple[Any, ...] | str | None = None + + # # -------------------------------------------------------------------- # ImageFile base class @@ -89,32 +110,34 @@ def _tilesort(t): class ImageFile(Image.Image): """Base class for image file format handlers.""" - def __init__(self, fp=None, filename=None): + def __init__( + self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None + ) -> None: super().__init__() self._min_frame = 0 - self.custom_mimetype = None + self.custom_mimetype: str | None = None - self.tile = None - """ A list of tile descriptors, or ``None`` """ + self.tile: list[_Tile] = [] + """ A list of tile descriptors """ self.readonly = 1 # until we know better - self.decoderconfig = () + self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK - if isPath(fp): + if is_path(fp): # filename self.fp = open(fp, "rb") - self.filename = fp + self.filename = os.fspath(fp) self._exclusive_fp = True else: # stream - self.fp = fp - self.filename = filename + self.fp = cast(IO[bytes], fp) + self.filename = filename if filename is not None else "" # can be overridden - self._exclusive_fp = None + self._exclusive_fp = False try: try: @@ -128,21 +151,30 @@ def __init__(self, fp=None, filename=None): ) as v: raise SyntaxError(v) from v - if not self.mode or self.size[0] <= 0: - raise SyntaxError("not identified by this driver") + if not self.mode or self.size[0] <= 0 or self.size[1] <= 0: + msg = "not identified by this driver" + raise SyntaxError(msg) except BaseException: # close the file only if we have opened it this constructor if self._exclusive_fp: self.fp.close() raise - def get_format_mimetype(self): + def _open(self) -> None: + pass + + def get_format_mimetype(self) -> str | None: if self.custom_mimetype: return self.custom_mimetype if self.format is not None: return Image.MIME.get(self.format.upper()) + return None + + def __setstate__(self, state: list[Any]) -> None: + self.tile = [] + super().__setstate__(state) - def verify(self): + def verify(self) -> None: """Check file integrity""" # raise exception if something's wrong. must be called @@ -151,17 +183,18 @@ def verify(self): self.fp.close() self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: """Load image data based on tile list""" - if self.tile is None: - raise OSError("cannot load this image") + if not self.tile and self._im is None: + msg = "cannot load this image" + raise OSError(msg) pixel = Image.Image.load(self) if not self.tile: return pixel - self.map = None + self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 # As of pypy 2.1.0, memory mapping was failing here. use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") @@ -169,47 +202,43 @@ def load(self): readonly = 0 # look for read/seek overrides - try: + if hasattr(self, "load_read"): read = self.load_read # don't use mmap if there are custom read/seek functions use_mmap = False - except AttributeError: + else: read = self.fp.read - try: + if hasattr(self, "load_seek"): seek = self.load_seek use_mmap = False - except AttributeError: + else: seek = self.fp.seek if use_mmap: # try memory mapping decoder_name, extents, offset, args = self.tile[0] + if isinstance(args, str): + args = (args, 0, 1) if ( decoder_name == "raw" + and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES ): try: - if hasattr(Image.core, "map"): - # use built-in mapper WIN32 only - self.map = Image.core.map(self.filename) - self.map.seek(offset) - self.im = self.map.readimage( - self.mode, self.size, args[1], args[2] - ) - else: - # use mmap, if possible - import mmap - - with open(self.filename) as fp: - self.map = mmap.mmap( - fp.fileno(), 0, access=mmap.ACCESS_READ - ) - self.im = Image.core.map_buffer( - self.map, self.size, decoder_name, offset, args - ) + # use mmap, if possible + import mmap + + with open(self.filename) as fp: + self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + if offset + self.size[1] * args[1] > self.map.size(): + msg = "buffer is not large enough" + raise OSError(msg) + self.im = Image.core.map_buffer( + self.map, self.size, decoder_name, offset, args + ) readonly = 1 # After trashing self.im, # we might need to reload the palette data. @@ -224,22 +253,26 @@ def load(self): # sort tiles in file order self.tile.sort(key=_tilesort) - try: - # FIXME: This is a hack to handle TIFF's JpegTables tag. - prefix = self.tile_prefix - except AttributeError: - prefix = b"" + # FIXME: This is a hack to handle TIFF's JpegTables tag. + prefix = getattr(self, "tile_prefix", b"") + # Remove consecutive duplicates that only differ by their offset + self.tile = [ + list(tiles)[-1] + for _, tiles in itertools.groupby( + self.tile, lambda tile: (tile[0], tile[1], tile[3]) + ) + ] for decoder_name, extents, offset, args in self.tile: + seek(offset) decoder = Image._getdecoder( self.mode, decoder_name, args, self.decoderconfig ) try: - seek(offset) decoder.setimage(self.im, extents) if decoder.pulls_fd: decoder.setfd(self.fp) - status, err_code = decoder.decode(b"") + err_code = decoder.decode(b"")[1] else: b = prefix while True: @@ -250,16 +283,18 @@ def load(self): if LOAD_TRUNCATED_IMAGES: break else: - raise OSError("image file is truncated") from e + msg = "image file is truncated" + raise OSError(msg) from e if not s: # truncated jpeg if LOAD_TRUNCATED_IMAGES: break else: - raise OSError( + msg = ( "image file is truncated " f"({len(b)} bytes not processed)" ) + raise OSError(msg) b = b + s n, err_code = decoder.decode(b) @@ -281,45 +316,55 @@ def load(self): if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0: # still raised if decoder fails to return anything - raise_oserror(err_code) + raise _get_oserror(err_code, encoder=False) return Image.Image.load(self) - def load_prepare(self): + def load_prepare(self) -> None: # create image memory if necessary - if not self.im or self.im.mode != self.mode or self.im.size != self.size: + if self._im is None: self.im = Image.core.new(self.mode, self.size) # create palette (optional) if self.mode == "P": Image.Image.load(self) - def load_end(self): + def load_end(self) -> None: # may be overridden pass # may be defined for contained formats - # def load_seek(self, pos): + # def load_seek(self, pos: int) -> None: # pass # may be defined for blocked formats (e.g. PNG) - # def load_read(self, bytes): + # def load_read(self, read_bytes: int) -> bytes: # pass - def _seek_check(self, frame): + def _seek_check(self, frame: int) -> bool: if ( frame < self._min_frame # Only check upper limit on frames if additional seek operations # are not required to do so or ( not (hasattr(self, "_n_frames") and self._n_frames is None) - and frame >= self.n_frames + self._min_frame + and frame >= getattr(self, "n_frames") + self._min_frame ) ): - raise EOFError("attempt to seek outside sequence") + msg = "attempt to seek outside sequence" + raise EOFError(msg) return self.tell() != frame +class StubHandler: + def open(self, im: StubImageFile) -> None: + pass + + @abc.abstractmethod + def load(self, im: StubImageFile) -> Image.Image: + pass + + class StubImageFile(ImageFile): """ Base class for stub image loaders. @@ -328,22 +373,26 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ - def _open(self): - raise NotImplementedError("StubImageFile subclass must implement _open") + def _open(self) -> None: + msg = "StubImageFile subclass must implement _open" + raise NotImplementedError(msg) - def load(self): + def load(self) -> Image.core.PixelAccess | None: loader = self._load() if loader is None: - raise OSError(f"cannot find loader for this {self.format} file") + msg = f"cannot find loader for this {self.format} file" + raise OSError(msg) image = loader.load(self) assert image is not None # become the other object (!) - self.__class__ = image.__class__ + self.__class__ = image.__class__ # type: ignore[assignment] self.__dict__ = image.__dict__ + return image.load() - def _load(self): + def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" - raise NotImplementedError("StubImageFile subclass must implement _load") + msg = "StubImageFile subclass must implement _load" + raise NotImplementedError(msg) class Parser: @@ -353,13 +402,13 @@ class Parser: """ incremental = None - image = None - data = None - decoder = None + image: Image.Image | None = None + data: bytes | None = None + decoder: Image.core.ImagingDecoder | PyDecoder | None = None offset = 0 finished = 0 - def reset(self): + def reset(self) -> None: """ (Consumer) Reset the parser. Note that you can only call this method immediately after you've created a parser; parser @@ -367,7 +416,7 @@ def reset(self): """ assert self.data is None, "cannot reuse parsers" - def feed(self, data): + def feed(self, data: bytes) -> None: """ (Consumer) Feed data to the parser. @@ -386,7 +435,6 @@ def feed(self, data): # parse what we have if self.decoder: - if self.offset > 0: # skip header skip = min(len(self.data), self.offset) @@ -404,27 +452,24 @@ def feed(self, data): if e < 0: # decoding error self.image = None - raise_oserror(e) + raise _get_oserror(e, encoder=False) else: # end of image return self.data = self.data[n:] elif self.image: - # if we end up here with no decoder, this file cannot # be incrementally parsed. wait until we've gotten all # available data pass else: - # attempt to open this file try: with io.BytesIO(self.data) as fp: im = Image.open(fp) except OSError: - # traceback.print_exc() pass # not enough data else: flag = hasattr(im, "load_seek") or hasattr(im, "load_read") @@ -447,13 +492,13 @@ def feed(self, data): self.image = im - def __enter__(self): + def __enter__(self) -> Parser: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() - def close(self): + def close(self) -> Image.Image: """ (Consumer) Close the stream. @@ -468,9 +513,11 @@ def close(self): self.feed(b"") self.data = self.decoder = None if not self.finished: - raise OSError("image was incomplete") + msg = "image was incomplete" + raise OSError(msg) if not self.image: - raise OSError("cannot parse this image") + msg = "cannot parse this image" + raise OSError(msg) if self.data: # incremental parsing not possible; reopen the file # not that we have all data @@ -485,7 +532,7 @@ def close(self): # -------------------------------------------------------------------- -def _save(im, fp, tile, bufsize=0): +def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -503,51 +550,52 @@ def _save(im, fp, tile, bufsize=0): # But, it would need at least the image size in most cases. RawEncode is # a tricky case. bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c - if fp == sys.stdout: - fp.flush() - return try: fh = fp.fileno() fp.flush() + _encode_tile(im, fp, tile, bufsize, fh) except (AttributeError, io.UnsupportedOperation) as exc: - # compress to Python file-compatible object - for e, b, o, a in tile: - e = Image._getencoder(im.mode, e, a, im.encoderconfig) - if o > 0: - fp.seek(o) - e.setimage(im.im, b) - if e.pushes_fd: - e.setfd(fp) - l, s = e.encode_to_pyfd() - else: - while True: - l, s, d = e.encode(bufsize) - fp.write(d) - if s: - break - if s < 0: - raise OSError(f"encoder error {s} when writing image file") from exc - e.cleanup() - else: - # slight speedup: compress to real file object - for e, b, o, a in tile: - e = Image._getencoder(im.mode, e, a, im.encoderconfig) - if o > 0: - fp.seek(o) - e.setimage(im.im, b) - if e.pushes_fd: - e.setfd(fp) - l, s = e.encode_to_pyfd() - else: - s = e.encode_to_file(fh, bufsize) - if s < 0: - raise OSError(f"encoder error {s} when writing image file") - e.cleanup() + _encode_tile(im, fp, tile, bufsize, None, exc) if hasattr(fp, "flush"): fp.flush() -def _safe_read(fp, size): +def _encode_tile( + im: Image.Image, + fp: IO[bytes], + tile: list[_Tile], + bufsize: int, + fh: int | None, + exc: BaseException | None = None, +) -> None: + for encoder_name, extents, offset, args in tile: + if offset > 0: + fp.seek(offset) + encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig) + try: + encoder.setimage(im.im, extents) + if encoder.pushes_fd: + encoder.setfd(fp) + errcode = encoder.encode_to_pyfd()[1] + else: + if exc: + # compress to Python file-compatible object + while True: + errcode, data = encoder.encode(bufsize)[1:] + fp.write(data) + if errcode: + break + else: + # slight speedup: compress to real file object + assert fh is not None + errcode = encoder.encode_to_file(fh, bufsize) + if errcode < 0: + raise _get_oserror(errcode, encoder=True) from exc + finally: + encoder.cleanup() + + +def _safe_read(fp: IO[bytes], size: int) -> bytes: """ Reads large blocks in a safe way. Unlike fp.read(n), this function doesn't trust the user. If the requested size is larger than @@ -555,94 +603,87 @@ def _safe_read(fp, size): :param fp: File handle. Must implement a read method. :param size: Number of bytes to read. - :returns: A string containing up to size bytes of data. + :returns: A string containing size bytes of data. + + Raises an OSError if the file is truncated and the read cannot be completed + """ if size <= 0: return b"" if size <= SAFEBLOCK: - return fp.read(size) - data = [] - while size > 0: - block = fp.read(min(size, SAFEBLOCK)) + data = fp.read(size) + if len(data) < size: + msg = "Truncated File Read" + raise OSError(msg) + return data + blocks: list[bytes] = [] + remaining_size = size + while remaining_size > 0: + block = fp.read(min(remaining_size, SAFEBLOCK)) if not block: break - data.append(block) - size -= len(block) - return b"".join(data) + blocks.append(block) + remaining_size -= len(block) + if sum(len(block) for block in blocks) < size: + msg = "Truncated File Read" + raise OSError(msg) + return b"".join(blocks) class PyCodecState: - def __init__(self): + def __init__(self) -> None: self.xsize = 0 self.ysize = 0 self.xoff = 0 self.yoff = 0 - def extents(self): - return (self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize) + def extents(self) -> tuple[int, int, int, int]: + return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize -class PyDecoder: - """ - Python implementation of a format decoder. Override this class and - add the decoding logic in the :meth:`decode` method. - - See :ref:`Writing Your Own File Decoder in Python` - """ +class PyCodec: + fd: IO[bytes] | None - _pulls_fd = False - - def __init__(self, mode, *args): - self.im = None + def __init__(self, mode: str, *args: Any) -> None: + self.im: Image.core.ImagingCore | None = None self.state = PyCodecState() self.fd = None self.mode = mode self.init(args) - def init(self, args): + def init(self, args: tuple[Any, ...]) -> None: """ - Override to perform decoder specific initialization + Override to perform codec specific initialization - :param args: Array of args items from the tile entry + :param args: Tuple of arg items from the tile entry :returns: None """ self.args = args - @property - def pulls_fd(self): - return self._pulls_fd - - def decode(self, buffer): - """ - Override to perform the decoding process. - - :param buffer: A bytes object with the data to be decoded. - :returns: A tuple of ``(bytes consumed, errcode)``. - If finished with decoding return <0 for the bytes consumed. - Err codes are from :data:`.ImageFile.ERRORS`. + def cleanup(self) -> None: """ - raise NotImplementedError() - - def cleanup(self): - """ - Override to perform decoder specific cleanup + Override to perform codec specific cleanup :returns: None """ pass - def setfd(self, fd): + def setfd(self, fd: IO[bytes]) -> None: """ - Called from ImageFile to set the python file-like object + Called from ImageFile to set the Python file-like object - :param fd: A python file-like object + :param fd: A Python file-like object :returns: None """ self.fd = fd - def setimage(self, im, extents=None): + def setimage( + self, + im: Image.core.ImagingCore, + extents: tuple[int, int, int, int] | None = None, + ) -> None: """ - Called from ImageFile to set the core output image for the decoder + Called from ImageFile to set the core output image for the codec :param im: A core image object :param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle @@ -667,31 +708,125 @@ def setimage(self, im, extents=None): self.state.ysize = y1 - y0 if self.state.xsize <= 0 or self.state.ysize <= 0: - raise ValueError("Size cannot be negative") + msg = "Size cannot be negative" + raise ValueError(msg) if ( self.state.xsize + self.state.xoff > self.im.size[0] or self.state.ysize + self.state.yoff > self.im.size[1] ): - raise ValueError("Tile cannot extend outside image") + msg = "Tile cannot extend outside image" + raise ValueError(msg) + + +class PyDecoder(PyCodec): + """ + Python implementation of a format decoder. Override this class and + add the decoding logic in the :meth:`decode` method. + + See :ref:`Writing Your Own File Codec in Python` + """ - def set_as_raw(self, data, rawmode=None): + _pulls_fd = False + + @property + def pulls_fd(self) -> bool: + return self._pulls_fd + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + """ + Override to perform the decoding process. + + :param buffer: A bytes object with the data to be decoded. + :returns: A tuple of ``(bytes consumed, errcode)``. + If finished with decoding return -1 for the bytes consumed. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base decoder" + raise NotImplementedError(msg) + + def set_as_raw( + self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = () + ) -> None: """ Convenience method to set the internal image from a stream of raw data :param data: Bytes to be set :param rawmode: The rawmode to be used for the decoder. If not specified, it will default to the mode of the image + :param extra: Extra arguments for the decoder. :returns: None """ if not rawmode: rawmode = self.mode - d = Image._getdecoder(self.mode, "raw", (rawmode)) + d = Image._getdecoder(self.mode, "raw", rawmode, extra) + assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) + + +class PyEncoder(PyCodec): + """ + Python implementation of a format encoder. Override this class and + add the decoding logic in the :meth:`encode` method. + + See :ref:`Writing Your Own File Codec in Python` + """ + + _pushes_fd = False + + @property + def pushes_fd(self) -> bool: + return self._pushes_fd + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + """ + Override to perform the encoding process. + + :param bufsize: Buffer size. + :returns: A tuple of ``(bytes encoded, errcode, bytes)``. + If finished with encoding return 1 for the error code. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base encoder" + raise NotImplementedError(msg) + + def encode_to_pyfd(self) -> tuple[int, int]: + """ + If ``pushes_fd`` is ``True``, then this method will be used, + and ``encode()`` will only be called once. + + :returns: A tuple of ``(bytes consumed, errcode)``. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + if not self.pushes_fd: + return 0, -8 # bad configuration + bytes_consumed, errcode, data = self.encode(0) + if data: + assert self.fd is not None + self.fd.write(data) + return bytes_consumed, errcode + + def encode_to_file(self, fh: int, bufsize: int) -> int: + """ + :param fh: File handle. + :param bufsize: Buffer size. + + :returns: If finished successfully, return 0. + Otherwise, return an error code. Err codes are from + :data:`.ImageFile.ERRORS`. + """ + errcode = 0 + while errcode == 0: + status, errcode, buf = self.encode(bufsize) + if status > 0: + os.write(fh, buf[status:]) + return errcode diff --git a/dependencies/OSX/PIL/ImageFilter.py b/dependencies/OSX/PIL/ImageFilter.py index 9ca17d9..b350e56 100644 --- a/dependencies/OSX/PIL/ImageFilter.py +++ b/dependencies/OSX/PIL/ImageFilter.py @@ -14,16 +14,23 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import functools +from collections.abc import Sequence +from types import ModuleType +from typing import TYPE_CHECKING, Any, Callable, cast -try: - import numpy -except ImportError: # pragma: no cover - numpy = None +if TYPE_CHECKING: + from . import _imaging + from ._typing import NumpyArray class Filter: - pass + @abc.abstractmethod + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + pass class MultibandFilter(Filter): @@ -31,38 +38,46 @@ class MultibandFilter(Filter): class BuiltinFilter(MultibandFilter): - def filter(self, image): + filterargs: tuple[Any, ...] + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) return image.filter(*self.filterargs) class Kernel(BuiltinFilter): """ - Create a convolution kernel. The current version only - supports 3x3 and 5x5 integer and floating point kernels. - - In the current version, kernels can only be applied to - "L" and "RGB" images. - - :param size: Kernel size, given as (width, height). In the current - version, this must be (3,3) or (5,5). - :param kernel: A sequence containing kernel weights. - :param scale: Scale factor. If given, the result for each pixel is - divided by this value. The default is the sum of the - kernel weights. - :param offset: Offset. If given, this value is added to the result, - after it has been divided by the scale factor. + Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating + point kernels. + + Kernels can only be applied to "L" and "RGB" images. + + :param size: Kernel size, given as (width, height). This must be (3,3) or (5,5). + :param kernel: A sequence containing kernel weights. The kernel will be flipped + vertically before being applied to the image. + :param scale: Scale factor. If given, the result for each pixel is divided by this + value. The default is the sum of the kernel weights. + :param offset: Offset. If given, this value is added to the result, after it has + been divided by the scale factor. """ name = "Kernel" - def __init__(self, size, kernel, scale=None, offset=0): + def __init__( + self, + size: tuple[int, int], + kernel: Sequence[float], + scale: float | None = None, + offset: float = 0, + ) -> None: if scale is None: # default scale is sum of kernel scale = functools.reduce(lambda a, b: a + b, kernel) if size[0] * size[1] != len(kernel): - raise ValueError("not enough coefficients in kernel") + msg = "not enough coefficients in kernel" + raise ValueError(msg) self.filterargs = size, scale, offset, kernel @@ -79,13 +94,14 @@ class RankFilter(Filter): name = "Rank" - def __init__(self, size, rank): + def __init__(self, size: int, rank: int) -> None: self.size = size self.rank = rank - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank) @@ -100,7 +116,7 @@ class MedianFilter(RankFilter): name = "Median" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size // 2 @@ -115,7 +131,7 @@ class MinFilter(RankFilter): name = "Min" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = 0 @@ -130,7 +146,7 @@ class MaxFilter(RankFilter): name = "Max" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size - 1 @@ -146,26 +162,34 @@ class ModeFilter(Filter): name = "Mode" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.modefilter(self.size) class GaussianBlur(MultibandFilter): - """Gaussian blur filter. + """Blurs the image with a sequence of extended box filters, which + approximates a Gaussian kernel. For details on accuracy see + - :param radius: Blur radius. + :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two + numbers for x and y, or a single number for both. """ name = "GaussianBlur" - def __init__(self, radius=2): + def __init__(self, radius: float | Sequence[float] = 2) -> None: self.radius = radius - def filter(self, image): - return image.gaussian_blur(self.radius) + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.gaussian_blur(xy) class BoxBlur(MultibandFilter): @@ -175,18 +199,29 @@ class BoxBlur(MultibandFilter): which runs in linear time relative to the size of the image for any radius value. - :param radius: Size of the box in one direction. Radius 0 does not blur, - returns an identical image. Radius 1 takes 1 pixel - in each direction, i.e. 9 pixels in total. + :param radius: Size of the box in a direction. Either a sequence of two numbers for + x and y, or a single number for both. + + Radius 0 does not blur, returns an identical image. + Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total. """ name = "BoxBlur" - def __init__(self, radius): + def __init__(self, radius: float | Sequence[float]) -> None: + xy = radius if isinstance(radius, (tuple, list)) else (radius, radius) + if xy[0] < 0 or xy[1] < 0: + msg = "radius must be >= 0" + raise ValueError(msg) self.radius = radius - def filter(self, image): - return image.box_blur(self.radius) + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.box_blur(xy) class UnsharpMask(MultibandFilter): @@ -202,16 +237,18 @@ class UnsharpMask(MultibandFilter): .. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking - """ # noqa: E501 + """ name = "UnsharpMask" - def __init__(self, radius=2, percent=150, threshold=3): + def __init__( + self, radius: float = 2, percent: int = 150, threshold: int = 3 + ) -> None: self.radius = radius self.percent = percent self.threshold = threshold - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.unsharp_mask(self.radius, self.percent, self.threshold) @@ -356,9 +393,17 @@ class Color3DLUT(MultibandFilter): name = "Color 3D LUT" - def __init__(self, size, table, channels=3, target_mode=None, **kwargs): + def __init__( + self, + size: int | tuple[int, int, int], + table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + channels: int = 3, + target_mode: str | None = None, + **kwargs: bool, + ) -> None: if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) self.size = size = self._check_size(size) self.channels = channels self.mode = target_mode @@ -369,16 +414,24 @@ def __init__(self, size, table, channels=3, target_mode=None, **kwargs): items = size[0] * size[1] * size[2] wrong_size = False + numpy: ModuleType | None = None + if hasattr(table, "shape"): + try: + import numpy + except ImportError: + pass + if numpy and isinstance(table, numpy.ndarray): + numpy_table: NumpyArray = table if copy_table: - table = table.copy() + numpy_table = numpy_table.copy() - if table.shape in [ + if numpy_table.shape in [ (items * channels,), (items, channels), (size[2], size[1], size[0], channels), ]: - table = table.reshape(items * channels) + table = numpy_table.reshape(items * channels) else: wrong_size = True @@ -388,42 +441,52 @@ def __init__(self, size, table, channels=3, target_mode=None, **kwargs): # Convert to a flat list if table and isinstance(table[0], (list, tuple)): - table, raw_table = [], table + raw_table = cast(Sequence[Sequence[int]], table) + flat_table: list[int] = [] for pixel in raw_table: if len(pixel) != channels: - raise ValueError( + msg = ( "The elements of the table should " - "have a length of {}.".format(channels) + f"have a length of {channels}." ) - table.extend(pixel) + raise ValueError(msg) + flat_table.extend(pixel) + table = flat_table if wrong_size or len(table) != items * channels: - raise ValueError( + msg = ( "The table should have either channels * size**3 float items " "or size**3 items of channels-sized tuples with floats. " f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. " f"Actual length: {len(table)}" ) + raise ValueError(msg) self.table = table @staticmethod - def _check_size(size): + def _check_size(size: Any) -> tuple[int, int, int]: try: _, _, _ = size except ValueError as e: - raise ValueError( - "Size should be either an integer or a tuple of three integers." - ) from e + msg = "Size should be either an integer or a tuple of three integers." + raise ValueError(msg) from e except TypeError: size = (size, size, size) - size = [int(x) for x in size] - for size1D in size: - if not 2 <= size1D <= 65: - raise ValueError("Size should be in [2, 65] range.") + size = tuple(int(x) for x in size) + for size_1d in size: + if not 2 <= size_1d <= 65: + msg = "Size should be in [2, 65] range." + raise ValueError(msg) return size @classmethod - def generate(cls, size, callback, channels=3, target_mode=None): + def generate( + cls, + size: int | tuple[int, int, int], + callback: Callable[[float, float, float], tuple[float, ...]], + channels: int = 3, + target_mode: str | None = None, + ) -> Color3DLUT: """Generates new LUT using provided callback. :param size: Size of the table. Passed to the constructor. @@ -435,29 +498,36 @@ def generate(cls, size, callback, channels=3, target_mode=None): :param target_mode: Passed to the constructor of the resulting lookup table. """ - size1D, size2D, size3D = cls._check_size(size) + size_1d, size_2d, size_3d = cls._check_size(size) if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) - table = [0] * (size1D * size2D * size3D * channels) + table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) idx_out = 0 - for b in range(size3D): - for g in range(size2D): - for r in range(size1D): + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): table[idx_out : idx_out + channels] = callback( - r / (size1D - 1), g / (size2D - 1), b / (size3D - 1) + r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1) ) idx_out += channels return cls( - (size1D, size2D, size3D), + (size_1d, size_2d, size_3d), table, channels=channels, target_mode=target_mode, _copy_table=False, ) - def transform(self, callback, with_normals=False, channels=None, target_mode=None): + def transform( + self, + callback: Callable[..., tuple[float, ...]], + with_normals: bool = False, + channels: int | None = None, + target_mode: str | None = None, + ) -> Color3DLUT: """Transforms the table values using provided callback and returns a new LUT with altered values. @@ -477,23 +547,24 @@ def transform(self, callback, with_normals=False, channels=None, target_mode=Non lookup table. """ if channels not in (None, 3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) ch_in = self.channels ch_out = channels or ch_in - size1D, size2D, size3D = self.size + size_1d, size_2d, size_3d = self.size - table = [0] * (size1D * size2D * size3D * ch_out) + table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out) idx_in = 0 idx_out = 0 - for b in range(size3D): - for g in range(size2D): - for r in range(size1D): + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): values = self.table[idx_in : idx_in + ch_in] if with_normals: values = callback( - r / (size1D - 1), - g / (size2D - 1), - b / (size3D - 1), + r / (size_1d - 1), + g / (size_2d - 1), + b / (size_3d - 1), *values, ) else: @@ -510,7 +581,7 @@ def transform(self, callback, with_normals=False, channels=None, target_mode=Non _copy_table=False, ) - def __repr__(self): + def __repr__(self) -> str: r = [ f"{self.__class__.__name__} from {self.table.__class__.__name__}", "size={:d}x{:d}x{:d}".format(*self.size), @@ -520,12 +591,12 @@ def __repr__(self): r.append(f"target_mode={self.mode}") return "<{}>".format(" ".join(r)) - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: from . import Image return image.color_lut_3d( self.mode or image.mode, - Image.LINEAR, + Image.Resampling.BILINEAR, self.channels, self.size[0], self.size[1], diff --git a/dependencies/OSX/PIL/ImageFont.py b/dependencies/OSX/PIL/ImageFont.py index 89d180c..d8c2655 100644 --- a/dependencies/OSX/PIL/ImageFont.py +++ b/dependencies/OSX/PIL/ImageFont.py @@ -25,28 +25,53 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + import base64 import os import sys +import warnings +from enum import IntEnum from io import BytesIO +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast + +from . import Image, features +from ._typing import StrOrBytesPath +from ._util import DeferredError, is_path + +if TYPE_CHECKING: + from . import ImageFile + from ._imaging import ImagingFont + from ._imagingft import Font -from . import Image -from ._util import isDirectory, isPath -LAYOUT_BASIC = 0 -LAYOUT_RAQM = 1 +class Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: bytes | None -class _imagingft_not_installed: - # module placeholder - def __getattr__(self, id): - raise ImportError("The _imagingft C module is not installed") +class Layout(IntEnum): + BASIC = 0 + RAQM = 1 +MAX_STRING_LENGTH = 1_000_000 + + +core: ModuleType | DeferredError try: from . import _imagingft as core -except ImportError: - core = _imagingft_not_installed() +except ImportError as ex: + core = DeferredError.new(ex) + + +def _string_length_check(text: str | bytes | bytearray) -> None: + if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: + msg = "too many characters in string" + raise ValueError(msg) # FIXME: add support for pilfont2 format (see FontFile.py) @@ -66,17 +91,20 @@ def __getattr__(self, id): class ImageFont: - "PIL font wrapper" + """PIL font wrapper""" - def _load_pilfont(self, filename): + font: ImagingFont + def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: - image = None + image: ImageFile.ImageFile | None = None + root = os.path.splitext(filename)[0] + for ext in (".png", ".gif", ".pbm"): if image: image.close() try: - fullname = os.path.splitext(filename)[0] + ext + fullname = root + ext image = Image.open(fullname) except Exception: pass @@ -86,18 +114,20 @@ def _load_pilfont(self, filename): else: if image: image.close() - raise OSError("cannot find glyph data file") + + msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}" + raise OSError(msg) self.file = fullname self._load_pilfont_data(fp, image) image.close() - def _load_pilfont_data(self, file, image): - + def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: # read PILfont header if file.readline() != b"PILfont\n": - raise SyntaxError("Not a PILfont file") + msg = "Not a PILfont file" + raise SyntaxError(msg) file.readline().split(b";") self.info = [] # FIXME: should be a dictionary while True: @@ -111,23 +141,16 @@ def _load_pilfont_data(self, file, image): # check image if image.mode not in ("1", "L"): - raise TypeError("invalid font image mode") + msg = "invalid font image mode" + raise TypeError(msg) image.load() self.font = Image.core.font(image.im, data) - def getsize(self, text, *args, **kwargs): - """ - Returns width and height (in pixels) of given text. - - :param text: Text to measure. - - :return: (width, height) - """ - return self.font.getsize(text) - - def getmask(self, text, mode="", *args, **kwargs): + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: """ Create a bitmap for the text. @@ -145,8 +168,39 @@ def getmask(self, text, mode="", *args, **kwargs): :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ + _string_length_check(text) + Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) + def getbbox( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> tuple[int, int, int, int]: + """ + Returns bounding box (in pixels) of given text. + + .. versionadded:: 9.2.0 + + :param text: Text to render. + + :return: ``(left, top, right, bottom)`` bounding box + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return 0, 0, width, height + + def getlength( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> int: + """ + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. + + .. versionadded:: 9.2.0 + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return width + ## # Wrapper for FreeType fonts. Application code should use the @@ -154,32 +208,69 @@ def getmask(self, text, mode="", *args, **kwargs): class FreeTypeFont: - "FreeType font wrapper (requires _imagingft service)" + """FreeType font wrapper (requires _imagingft service)""" - def __init__(self, font=None, size=10, index=0, encoding="", layout_engine=None): + font: Font + font_bytes: bytes + + def __init__( + self, + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, + ) -> None: # FIXME: use service provider instead + if isinstance(core, DeferredError): + raise core.ex + + if size <= 0: + msg = f"font size must be greater than 0, not {size}" + raise ValueError(msg) + self.path = font self.size = size self.index = index self.encoding = encoding - if layout_engine not in (LAYOUT_BASIC, LAYOUT_RAQM): - layout_engine = LAYOUT_BASIC + try: + from packaging.version import parse as parse_version + except ImportError: + pass + else: + if freetype_version := features.version_module("freetype2"): + if parse_version(freetype_version) < parse_version("2.9.1"): + warnings.warn( + "Support for FreeType 2.9.0 is deprecated and will be removed " + "in Pillow 12 (2025-10-15). Please upgrade to FreeType 2.9.1 " + "or newer, preferably FreeType 2.10.4 which fixes " + "CVE-2020-15999.", + DeprecationWarning, + ) + + if layout_engine not in (Layout.BASIC, Layout.RAQM): + layout_engine = Layout.BASIC if core.HAVE_RAQM: - layout_engine = LAYOUT_RAQM - elif layout_engine == LAYOUT_RAQM and not core.HAVE_RAQM: - layout_engine = LAYOUT_BASIC + layout_engine = Layout.RAQM + elif layout_engine == Layout.RAQM and not core.HAVE_RAQM: + warnings.warn( + "Raqm layout was requested, but Raqm is not available. " + "Falling back to basic layout." + ) + layout_engine = Layout.BASIC self.layout_engine = layout_engine - def load_from_bytes(f): + def load_from_bytes(f: IO[bytes]) -> None: self.font_bytes = f.read() self.font = core.getfont( "", size, index, encoding, self.font_bytes, layout_engine ) - if isPath(font): + if is_path(font): + font = os.fspath(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -194,20 +285,23 @@ def load_from_bytes(f): font, size, index, encoding, layout_engine=layout_engine ) else: - load_from_bytes(font) + load_from_bytes(cast(IO[bytes], font)) + + def __getstate__(self) -> list[Any]: + return [self.path, self.size, self.index, self.encoding, self.layout_engine] - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" - return text.split(split_character) + def __setstate__(self, state: list[Any]) -> None: + path, size, index, encoding, layout_engine = state + FreeTypeFont.__init__(self, path, size, index, encoding, layout_engine) - def getname(self): + def getname(self) -> tuple[str | None, str | None]: """ :return: A tuple of the font family (e.g. Helvetica) and the font style (e.g. Bold) """ return self.font.family, self.font.style - def getmetrics(self): + def getmetrics(self) -> tuple[int, int]: """ :return: A tuple of the font ascent (the distance from the baseline to the highest outline point) and descent (the distance from the @@ -215,7 +309,14 @@ def getmetrics(self): """ return self.font.ascent, self.font.descent - def getlength(self, text, mode="", direction=None, features=None, language=None): + def getlength( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -230,27 +331,21 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) string due to kerning. If you need to adjust for kerning, include the following character and subtract its length. - For example, instead of - - .. code-block:: python + For example, instead of :: hello = font.getlength("Hello") world = font.getlength("World") hello_world = hello + world # not adjusted for kerning assert hello_world == font.getlength("HelloWorld") # may fail - use - - .. code-block:: python + use :: hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning world = font.getlength("World") hello_world = hello + world # adjusted for kerning assert hello_world == font.getlength("HelloWorld") # True - or disable kerning with (requires libraqm) - - .. code-block:: python + or disable kerning with (requires libraqm) :: hello = draw.textlength("Hello", font, features=["-kern"]) world = draw.textlength("World", font, features=["-kern"]) @@ -277,7 +372,7 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -285,23 +380,24 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) the font which language the text is in, and to apply the correct substitutions as appropriate, if available. It should be a `BCP 47 language code - ` + `_ Requires libraqm. - :return: Width for horizontal, height for vertical text. + :return: Either width for horizontal text, or height for vertical text. """ + _string_length_check(text) return self.font.getlength(text, mode, direction, features, language) / 64 def getbbox( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ) -> tuple[float, float, float, float]: """ Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -330,7 +426,7 @@ def getbbox( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -338,17 +434,19 @@ def getbbox( the font which language the text is in, and to apply the correct substitutions as appropriate, if available. It should be a `BCP 47 language code - ` + `_ Requires libraqm. :param stroke_width: The width of the text stroke. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. :return: ``(left, top, right, bottom)`` bounding box """ + _string_length_check(text) size, offset = self.font.getsize( text, mode, direction, features, language, anchor ) @@ -356,150 +454,18 @@ def getbbox( width, height = size[0] + 2 * stroke_width, size[1] + 2 * stroke_width return left, top, left + width, top + height - def getsize( - self, text, direction=None, features=None, language=None, stroke_width=0 - ): - """ - Returns width and height (in pixels) of given text if rendered in font with - provided direction, features, and language. - - Use :py:meth:`getlength()` to measure the offset of following text with - 1/64 pixel precision. - Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor. - - .. note:: For historical reasons this function measures text height from - the ascender line instead of the top, see :ref:`text-anchors`. - If you wish to measure text height from the top, it is recommended - to use the bottom value of :meth:`getbbox` with ``anchor='lt'`` instead. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - ` - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - # vertical offset is added for historical reasons - # see https://github.com/python-pillow/Pillow/pull/4910#discussion_r486682929 - size, offset = self.font.getsize(text, "L", direction, features, language) - return ( - size[0] + stroke_width * 2, - size[1] + stroke_width * 2 + offset[1], - ) - - def getsize_multiline( - self, - text, - direction=None, - spacing=4, - features=None, - language=None, - stroke_width=0, - ): - """ - Returns width and height (in pixels) of given text if rendered in font - with provided direction, features, and language, while respecting - newline characters. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - :param spacing: The vertical gap between lines, defaulting to 4 pixels. - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - ` - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - max_width = 0 - lines = self._multiline_split(text) - line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing - for line in lines: - line_width, line_height = self.getsize( - line, direction, features, language, stroke_width - ) - max_width = max(max_width, line_width) - - return max_width, len(lines) * line_spacing - spacing - - def getoffset(self, text): - """ - Returns the offset of given text. This is the gap between the - starting coordinate and the first marking. Note that this gap is - included in the result of :py:func:`~PIL.ImageFont.FreeTypeFont.getsize`. - - :param text: Text to measure. - - :return: A tuple of the x and y offset - """ - return self.font.getsize(text)[1] - def getmask( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ink=0, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + ) -> Image.core.ImagingCore: """ Create a bitmap for the text. @@ -529,7 +495,7 @@ def getmask( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -539,7 +505,7 @@ def getmask( the font which language the text is in, and to apply the correct substitutions as appropriate, if available. It should be a `BCP 47 language code - ` + `_ Requires libraqm. .. versionadded:: 6.0.0 @@ -549,8 +515,9 @@ def getmask( .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -558,6 +525,11 @@ def getmask( .. versionadded:: 8.0.0 + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ @@ -570,22 +542,23 @@ def getmask( stroke_width=stroke_width, anchor=anchor, ink=ink, + start=start, )[0] def getmask2( self, - text, - mode="", - fill=Image.core.fill, - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ink=0, - *args, - **kwargs, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + *args: Any, + **kwargs: Any, + ) -> tuple[Image.core.ImagingCore, tuple[int, int]]: """ Create a bitmap for the text. @@ -615,7 +588,7 @@ def getmask2( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -625,7 +598,7 @@ def getmask2( the font which language the text is in, and to apply the correct substitutions as appropriate, if available. It should be a `BCP 47 language code - ` + `_ Requires libraqm. .. versionadded:: 6.0.0 @@ -635,8 +608,9 @@ def getmask2( .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -644,24 +618,46 @@ def getmask2( .. versionadded:: 8.0.0 + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + :return: A tuple of an internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module, and the text offset, the gap between the starting coordinate and the first marking """ - size, offset = self.font.getsize( - text, mode, direction, features, language, anchor - ) - size = size[0] + stroke_width * 2, size[1] + stroke_width * 2 - offset = offset[0] - stroke_width, offset[1] - stroke_width - im = fill("RGBA" if mode == "RGBA" else "L", size, 0) - self.font.render( - text, im.id, mode, direction, features, language, stroke_width, ink + _string_length_check(text) + if start is None: + start = (0, 0) + + def fill(width: int, height: int) -> Image.core.ImagingCore: + size = (width, height) + Image._decompression_bomb_check(size) + return Image.core.fill("RGBA" if mode == "RGBA" else "L", size) + + return self.font.render( + text, + fill, + mode, + direction, + features, + language, + stroke_width, + anchor, + ink, + start[0], + start[1], ) - return im, offset def font_variant( - self, font=None, size=None, index=None, encoding=None, layout_engine=None - ): + self, + font: StrOrBytesPath | BinaryIO | None = None, + size: float | None = None, + index: int | None = None, + encoding: str | None = None, + layout_engine: Layout | None = None, + ) -> FreeTypeFont: """ Create a copy of this FreeTypeFont object, using any specified arguments to override the settings. @@ -671,15 +667,20 @@ def font_variant( :return: A FreeTypeFont object. """ + if font is None: + try: + font = BytesIO(self.font_bytes) + except AttributeError: + font = self.path return FreeTypeFont( - font=self.path if font is None else font, + font=font, size=self.size if size is None else size, index=self.index if index is None else index, encoding=self.encoding if encoding is None else encoding, layout_engine=layout_engine or self.layout_engine, ) - def get_variation_names(self): + def get_variation_names(self) -> list[bytes]: """ :returns: A list of the named styles in a variation font. :exception OSError: If the font is not a variation font. @@ -687,10 +688,11 @@ def get_variation_names(self): try: names = self.font.getvarnames() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e return [name.replace(b"\x00", b"") for name in names] - def set_variation_by_name(self, name): + def set_variation_by_name(self, name: str | bytes) -> None: """ :param name: The name of the style. :exception OSError: If the font is not a variation font. @@ -698,7 +700,7 @@ def set_variation_by_name(self, name): names = self.get_variation_names() if not isinstance(name, bytes): name = name.encode() - index = names.index(name) + index = names.index(name) + 1 if index == getattr(self, "_last_variation_index", None): # When the same name is set twice in a row, @@ -709,7 +711,7 @@ def set_variation_by_name(self, name): self.font.setvarname(index) - def get_variation_axes(self): + def get_variation_axes(self) -> list[Axis]: """ :returns: A list of the axes in a variation font. :exception OSError: If the font is not a variation font. @@ -717,12 +719,14 @@ def get_variation_axes(self): try: axes = self.font.getvaraxes() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e for axis in axes: - axis["name"] = axis["name"].replace(b"\x00", b"") + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") return axes - def set_variation_by_axes(self, axes): + def set_variation_by_axes(self, axes: list[float]) -> None: """ :param axes: A list of values for each axis. :exception OSError: If the font is not a variation font. @@ -730,42 +734,61 @@ def set_variation_by_axes(self, axes): try: self.font.setvaraxes(axes) except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e class TransposedFont: - "Wrapper for writing rotated or mirrored text" + """Wrapper for writing rotated or mirrored text""" - def __init__(self, font, orientation=None): + def __init__( + self, font: ImageFont | FreeTypeFont, orientation: Image.Transpose | None = None + ): """ Wrapper that creates a transposed font from any existing font object. :param font: A font object. :param orientation: An optional orientation. If given, this should - be one of Image.FLIP_LEFT_RIGHT, Image.FLIP_TOP_BOTTOM, - Image.ROTATE_90, Image.ROTATE_180, or Image.ROTATE_270. + be one of Image.Transpose.FLIP_LEFT_RIGHT, Image.Transpose.FLIP_TOP_BOTTOM, + Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_180, or + Image.Transpose.ROTATE_270. """ self.font = font self.orientation = orientation # any 'transpose' argument, or None - def getsize(self, text, *args, **kwargs): - w, h = self.font.getsize(text) - if self.orientation in (Image.ROTATE_90, Image.ROTATE_270): - return h, w - return w, h - - def getmask(self, text, mode="", *args, **kwargs): + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: im = self.font.getmask(text, mode, *args, **kwargs) if self.orientation is not None: return im.transpose(self.orientation) return im - -def load(filename): + def getbbox( + self, text: str | bytes, *args: Any, **kwargs: Any + ) -> tuple[int, int, float, float]: + # TransposedFont doesn't support getmask2, move top-left point to (0, 0) + # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont + left, top, right, bottom = self.font.getbbox(text, *args, **kwargs) + width = right - left + height = bottom - top + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + return 0, 0, height, width + return 0, 0, width, height + + def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> float: + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + msg = "text length is undefined for text rotated by 90 or 270 degrees" + raise ValueError(msg) + return self.font.getlength(text, *args, **kwargs) + + +def load(filename: str) -> ImageFont: """ - Load a font file. This function loads a font object from the given - bitmap font file, and returns the corresponding font object. + Load a font file. This function loads a font object from the given + bitmap font file, and returns the corresponding font object. For loading TrueType + or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`. :param filename: Name of font file. :return: A font object. @@ -776,28 +799,42 @@ def load(filename): return f -def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): +def truetype( + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, +) -> FreeTypeFont: """ Load a TrueType or OpenType font from a file or file-like object, - and create a font object. - This function loads a font object from the given file or file-like - object, and creates a font object for a font of the given size. - - Pillow uses FreeType to open font files. If you are opening many fonts - simultaneously on Windows, be aware that Windows limits the number of files - that can be open in C at once to 512. If you approach that limit, an + and create a font object. This function loads a font object from the given + file or file-like object, and creates a font object for a font of the given + size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load` + and :py:func:`~PIL.ImageFont.load_path`. + + Pillow uses FreeType to open font files. On Windows, be aware that FreeType + will keep the file open as long as the FreeTypeFont object exists. Windows + limits the number of files that can be open in C at once to 512, so if many + fonts are opened simultaneously and that limit is approached, an ``OSError`` may be thrown, reporting that FreeType "cannot open resource". + A workaround would be to copy the file(s) into memory, and open that instead. This function requires the _imagingft service. :param font: A filename or file-like object containing a TrueType font. If the file is not found in this filename, the loader may also - search in other directories, such as the :file:`fonts/` - directory on Windows or :file:`/Library/Fonts/`, - :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on - macOS. + search in other directories, such as: + + * The :file:`fonts/` directory on Windows, + * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` + and :file:`~/Library/Fonts/` on macOS. + * :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`, + and :file:`/usr/share/fonts` on Linux; or those specified by + the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables + for user-installed and system-wide fonts, respectively. - :param size: The requested size, in points. + :param size: The requested size, in pixels. :param index: Which font face to load (default is first available face). :param encoding: Which font encoding to use (default is Unicode). Possible encodings include (see the FreeType documentation for more @@ -819,7 +856,12 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): This specifies the character set to use. It does not alter the encoding of any text provided in subsequent operations. :param layout_engine: Which layout engine to use, if available: - :data:`.ImageFont.LAYOUT_BASIC` or :data:`.ImageFont.LAYOUT_RAQM`. + :attr:`.ImageFont.Layout.BASIC` or :attr:`.ImageFont.Layout.RAQM`. + If it is available, Raqm layout will be used by default. + Otherwise, basic layout will be used. + + Raqm layout is recommended for all non-English text. If Raqm layout + is not required, basic layout will have better performance. You can check support for Raqm layout using :py:func:`PIL.features.check_feature` with ``feature="raqm"``. @@ -827,15 +869,16 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): .. versionadded:: 4.2.0 :return: A font object. :exception OSError: If the file could not be read. + :exception ValueError: If the font size is not greater than zero. """ - def freetype(font): + def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: return freetype(font) except OSError: - if not isPath(font): + if not is_path(font): raise ttf_filename = os.path.basename(font) @@ -848,12 +891,21 @@ def freetype(font): if windir: dirs.append(os.path.join(windir, "fonts")) elif sys.platform in ("linux", "linux2"): - lindirs = os.environ.get("XDG_DATA_DIRS", "") - if not lindirs: - # According to the freedesktop spec, XDG_DATA_DIRS should - # default to /usr/share - lindirs = "/usr/share" - dirs += [os.path.join(lindir, "fonts") for lindir in lindirs.split(":")] + data_home = os.environ.get("XDG_DATA_HOME") + if not data_home: + # The freedesktop spec defines the following default directory for + # when XDG_DATA_HOME is unset or empty. This user-level directory + # takes precedence over system-level directories. + data_home = os.path.expanduser("~/.local/share") + xdg_dirs = [data_home] + + data_dirs = os.environ.get("XDG_DATA_DIRS") + if not data_dirs: + # Similarly, defaults are defined for the system-level directories + data_dirs = "/usr/local/share:/usr/share" + xdg_dirs += data_dirs.split(":") + + dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs] elif sys.platform == "darwin": dirs += [ "/Library/Fonts", @@ -879,7 +931,7 @@ def freetype(font): raise -def load_path(filename): +def load_path(filename: str | bytes) -> ImageFont: """ Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a bitmap font along the Python path. @@ -888,24 +940,21 @@ def load_path(filename): :return: A font object. :exception OSError: If the file could not be read. """ + if not isinstance(filename, str): + filename = filename.decode("utf-8") for directory in sys.path: - if isDirectory(directory): - if not isinstance(filename, str): - filename = filename.decode("utf-8") - try: - return load(os.path.join(directory, filename)) - except OSError: - pass - raise OSError("cannot find font file") - + try: + return load(os.path.join(directory, filename)) + except OSError: + pass + msg = f'cannot find font file "{filename}" in sys.path' + if os.path.exists(filename): + msg += f', did you mean ImageFont.load("{filename}") instead?' -def load_default(): - """Load a "better than nothing" default font. + raise OSError(msg) - .. versionadded:: 1.1.4 - :return: A font object. - """ +def load_default_imagefont() -> ImageFont: f = ImageFont() f._load_pilfont_data( # courB08 @@ -1039,3 +1088,251 @@ def load_default(): ), ) return f + + +def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: + """If FreeType support is available, load a version of Aileron Regular, + https://dotcolon.net/font/aileron, with a more limited character set. + + Otherwise, load a "better than nothing" font. + + .. versionadded:: 1.1.4 + + :param size: The font size of Aileron Regular. + + .. versionadded:: 10.1.0 + + :return: A font object. + """ + if isinstance(core, ModuleType) or size is not None: + return truetype( + BytesIO( + base64.b64decode( + b""" +AAEAAAAPAIAAAwBwRkZUTYwDlUAAADFoAAAAHEdERUYAqADnAAAo8AAAACRHUE9ThhmITwAAKfgAA +AduR1NVQnHxefoAACkUAAAA4k9TLzJovoHLAAABeAAAAGBjbWFw5lFQMQAAA6gAAAGqZ2FzcP//AA +MAACjoAAAACGdseWYmRXoPAAAGQAAAHfhoZWFkE18ayQAAAPwAAAA2aGhlYQboArEAAAE0AAAAJGh +tdHjjERZ8AAAB2AAAAdBsb2NhuOexrgAABVQAAADqbWF4cAC7AEYAAAFYAAAAIG5hbWUr+h5lAAAk +OAAAA6Jwb3N0D3oPTQAAJ9wAAAEKAAEAAAABGhxJDqIhXw889QALA+gAAAAA0Bqf2QAAAADhCh2h/ +2r/LgOxAyAAAAAIAAIAAAAAAAAAAQAAA8r/GgAAA7j/av9qA7EAAQAAAAAAAAAAAAAAAAAAAHQAAQ +AAAHQAQwAFAAAAAAACAAAAAQABAAAAQAAAAAAAAAADAfoBkAAFAAgCigJYAAAASwKKAlgAAAFeADI +BPgAAAAAFAAAAAAAAAAAAAAcAAAAAAAAAAAAAAABVS1dOAEAAIPsCAwL/GgDIA8oA5iAAAJMAAAAA +AhICsgAAACAAAwH0AAAAAAAAAU0AAADYAAAA8gA5AVMAVgJEAEYCRAA1AuQAKQKOAEAAsAArATsAZ +AE7AB4CMABVAkQAUADc/+EBEgAgANwAJQEv//sCRAApAkQAggJEADwCRAAtAkQAIQJEADkCRAArAk +QAMgJEACwCRAAxANwAJQDc/+ECRABnAkQAUAJEAEQB8wAjA1QANgJ/AB0CcwBkArsALwLFAGQCSwB +kAjcAZALGAC8C2gBkAQgAZAIgADcCYQBkAj8AZANiAGQCzgBkAuEALwJWAGQC3QAvAmsAZAJJADQC +ZAAiAqoAXgJuACADuAAaAnEAGQJFABMCTwAuATMAYgEv//sBJwAiAkQAUAH0ADIBLAApAhMAJAJjA +EoCEQAeAmcAHgIlAB4BIgAVAmcAHgJRAEoA7gA+AOn/8wIKAEoA9wBGA1cASgJRAEoCSgAeAmMASg +JnAB4BSgBKAcsAGAE5ABQCUABCAgIAAQMRAAEB4v/6AgEAAQHOABQBLwBAAPoAYAEvACECRABNA0Y +AJAItAHgBKgAcAkQAUAEsAHQAygAgAi0AOQD3ADYA9wAWAaEANgGhABYCbAAlAYMAeAGDADkA6/9q +AhsAFAIKABUB/QAVAAAAAwAAAAMAAAAcAAEAAAAAAKQAAwABAAAAHAAEAIgAAAAeABAAAwAOAH4Aq +QCrALEAtAC3ALsgGSAdICYgOiBEISL7Av//AAAAIACpAKsAsAC0ALcAuyAYIBwgJiA5IEQhIvsB// +//4/+5/7j/tP+y/7D/reBR4E/gR+A14CzfTwVxAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAEGAAABAAAAAAAAAAECAAAAAgAAAAAAAAAAAAAAAAAAAAEAAAMEBQYHCAkKCwwNDg8QERIT +FBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMT +U5PUFFSU1RVVldYWVpbXF1eX2BhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAA +AAAAAAYnFmAAAAAABlAAAAAAAAAAAAAAAAAAAAAAAAAAAAY2htAAAAAAAAAABrbGlqAAAAAHAAbm9 +ycwBnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmACYAJgAmAD4AUgCCAMoBCgFO +AVwBcgGIAaYBvAHKAdYB6AH2AgwCIAJKAogCpgLWAw4DIgNkA5wDugPUA+gD/AQQBEYEogS8BPoFJ +gVSBWoFgAWwBcoF1gX6BhQGJAZMBmgGiga0BuIHGgdUB2YHkAeiB8AH3AfyCAoIHAgqCDoITghcCG +oIogjSCPoJKglYCXwJwgnqCgIKKApACl4Klgq8CtwLDAs8C1YLjAuyC9oL7gwMDCYMSAxgDKAMrAz +qDQoNTA1mDYQNoA2uDcAN2g3oDfYODA4iDkoOXA5sDnoOnA7EDvwAAAAFAAAAAAH0ArwAAwAGAAkA +DAAPAAAxESERAxMhExcRASELARETAfT6qv6syKr+jgFUqsiqArz9RAGLAP/+1P8B/v3VAP8BLP4CA +P8AAgA5//IAuQKyAAMACwAANyMDMwIyFhQGIiY0oE4MZk84JCQ4JLQB/v3AJDgkJDgAAgBWAeUBPA +LfAAMABwAAEyMnMxcjJzOmRgpagkYKWgHl+vr6AAAAAAIARgAAAf4CsgAbAB8AAAEHMxUjByM3Iwc +jNyM1MzcjNTM3MwczNzMHMxUrAQczAZgdZXEvOi9bLzovWmYdZXEvOi9bLzovWp9bHlsBn4w429vb +2ziMONvb29s4jAAAAAMANf+mAg4DDAAfACYALAAAJRQGBxUjNS4BJzMeARcRLgE0Njc1MxUeARcjJ +icVHgEBFBYXNQ4BExU+ATU0Ag5xWDpgcgRcBz41Xl9oVTpVYwpcC1ttXP6cLTQuM5szOrVRZwlOTQ +ZqVzZECAEAGlukZAlOTQdrUG8O7iNlAQgxNhDlCDj+8/YGOjReAAAAAAUAKf/yArsCvAAHAAsAFQA +dACcAABIyFhQGIiY0EyMBMwQiBhUUFjI2NTQSMhYUBiImNDYiBhUUFjI2NTR5iFBQiFCVVwHAV/5c +OiMjOiPmiFBQiFCxOiMjOiMCvFaSVlaS/ZoCsjIzMC80NC8w/uNWklZWkhozMC80NC8wAAAAAgBA/ +/ICbgLAACIALgAAARUjEQYjIiY1NDY3LgE1NDYzMhcVJiMiBhUUFhcWOwE1MxUFFBYzMjc1IyIHDg +ECbmBcYYOOVkg7R4hsQjY4Q0RNRD4SLDxW/pJUXzksPCkUUk0BgUb+zBVUZ0BkDw5RO1huCkULQzp +COAMBcHDHRz0J/AIHRQAAAAEAKwHlAIUC3wADAAATIycze0YKWgHl+gAAAAABAGT/sAEXAwwACQAA +EzMGEBcjLgE0Nt06dXU6OUBAAwzG/jDGVePs4wAAAAEAHv+wANEDDAAJAAATMx4BFAYHIzYQHjo5Q +EA5OnUDDFXj7ONVxgHQAAAAAQBVAFIB2wHbAA4AAAE3FwcXBycHJzcnNxcnMwEtmxOfcTJjYzJxnx +ObCj4BKD07KYolmZkliik7PbMAAQBQAFUB9AIlAAsAAAEjFSM1IzUzNTMVMwH0tTq1tTq1AR/Kyjj +OzgAAAAAB/+H/iACMAGQABAAANwcjNzOMWlFOXVrS3AAAAQAgAP8A8gE3AAMAABMjNTPy0tIA/zgA +AQAl//IApQByAAcAADYyFhQGIiY0STgkJDgkciQ4JCQ4AAAAAf/7/+IBNALQAAMAABcjEzM5Pvs+H +gLuAAAAAAIAKf/yAhsCwAADAAcAABIgECA2IBAgKQHy/g5gATL+zgLA/TJEAkYAAAAAAQCCAAABlg +KyAAgAAAERIxEHNTc2MwGWVr6SIygCsv1OAldxW1sWAAEAPAAAAg4CwAAZAAA3IRUhNRM+ATU0JiM +iDwEjNz4BMzIWFRQGB7kBUv4x+kI2QTt+EAFWAQp8aGVtSl5GRjEA/0RVLzlLmAoKa3FsUkNxXQAA +AAEALf/yAhYCwAAqAAABHgEVFAYjIi8BMxceATMyNjU0KwE1MzI2NTQmIyIGDwEjNz4BMzIWFRQGA +YxBSZJo2RUBVgEHV0JBUaQREUBUQzc5TQcBVgEKfGhfcEMBbxJbQl1x0AoKRkZHPn9GSD80QUVCCg +pfbGBPOlgAAAACACEAAAIkArIACgAPAAAlIxUjNSE1ATMRMyMRBg8BAiRXVv6qAVZWV60dHLCurq4 +rAdn+QgFLMibzAAABADn/8gIZArIAHQAAATIWFRQGIyIvATMXFjMyNjU0JiMiByMTIRUhBzc2ATNv +d5Fl1RQBVgIad0VSTkVhL1IwAYj+vh8rMAHHgGdtgcUKCoFXTU5bYgGRRvAuHQAAAAACACv/8gITA +sAAFwAjAAABMhYVFAYjIhE0NjMyFh8BIycmIyIDNzYTMjY1NCYjIgYVFBYBLmp7imr0l3RZdAgBXA +IYZ5wKJzU6QVNJSz5SUAHSgWltiQFGxcNlVQoKdv7sPiz+ZF1LTmJbU0lhAAAAAQAyAAACGgKyAAY +AAAEVASMBITUCGv6oXAFL/oECsij9dgJsRgAAAAMALP/xAhgCwAAWACAALAAAAR4BFRQGIyImNTQ2 +Ny4BNTQ2MhYVFAYmIgYVFBYyNjU0AzI2NTQmIyIGFRQWAZQ5S5BmbIpPOjA7ecp5P2F8Q0J8RIVJS +0pLTEtOAW0TXTxpZ2ZqPF0SE1A3VWVlVTdQ/UU0N0RENzT9/ko+Ok1NOj1LAAIAMf/yAhkCwAAXAC +MAAAEyERQGIyImLwEzFxYzMhMHBiMiJjU0NhMyNjU0JiMiBhUUFgEl9Jd0WXQIAVwCGGecCic1SWp +7imo+UlBAQVNJAsD+usXDZVUKCnYBFD4sgWltif5kW1NJYV1LTmIAAAACACX/8gClAiAABwAPAAAS +MhYUBiImNBIyFhQGIiY0STgkJDgkJDgkJDgkAiAkOCQkOP52JDgkJDgAAAAC/+H/iAClAiAABwAMA +AASMhYUBiImNBMHIzczSTgkJDgkaFpSTl4CICQ4JCQ4/mba5gAAAQBnAB4B+AH0AAYAAAENARUlNS +UB+P6qAVb+bwGRAbCmpkbJRMkAAAIAUAC7AfQBuwADAAcAAAEhNSERITUhAfT+XAGk/lwBpAGDOP8 +AOAABAEQAHgHVAfQABgAAARUFNS0BNQHV/m8BVv6qAStEyUSmpkYAAAAAAgAj//IB1ALAABgAIAAA +ATIWFRQHDgEHIz4BNz4BNTQmIyIGByM+ARIyFhQGIiY0AQRibmktIAJWBSEqNig+NTlHBFoDezQ4J +CQ4JALAZ1BjaS03JS1DMD5LLDQ/SUVgcv2yJDgkJDgAAAAAAgA2/5gDFgKYADYAQgAAAQMGFRQzMj +Y1NCYjIg4CFRQWMzI2NxcGIyImNTQ+AjMyFhUUBiMiJwcGIyImNTQ2MzIfATcHNzYmIyIGFRQzMjY +Cej8EJjJJlnBAfGQ+oHtAhjUYg5OPx0h2k06Os3xRWQsVLjY5VHtdPBwJETcJDyUoOkZEJz8B0f74 +EQ8kZl6EkTFZjVOLlyknMVm1pmCiaTq4lX6CSCknTVRmmR8wPdYnQzxuSWVGAAIAHQAAAncCsgAHA +AoAACUjByMTMxMjATMDAcj+UVz4dO5d/sjPZPT0ArL9TgE6ATQAAAADAGQAAAJMArIAEAAbACcAAA +EeARUUBgcGKwERMzIXFhUUJRUzMjc2NTQnJiMTPgE1NCcmKwEVMzIBvkdHZkwiNt7LOSGq/oeFHBt +hahIlSTM+cB8Yj5UWAW8QT0VYYgwFArIEF5Fv1eMED2NfDAL93AU+N24PBP0AAAAAAQAv//ICjwLA +ABsAAAEyFh8BIycmIyIGFRQWMzI/ATMHDgEjIiY1NDYBdX+PCwFWAiKiaHx5ZaIiAlYBCpWBk6a0A +sCAagoKpqN/gaOmCgplhcicn8sAAAIAZAAAAp8CsgAMABkAAAEeARUUBgcGKwERMzITPgE1NCYnJi +sBETMyAY59lJp8IzXN0jUVWmdjWRs5d3I4Aq4QqJWUug8EArL9mQ+PeHGHDgX92gAAAAABAGQAAAI +vArIACwAAJRUhESEVIRUhFSEVAi/+NQHB/pUBTf6zRkYCskbwRvAAAAABAGQAAAIlArIACQAAExUh +FSERIxEhFboBQ/69VgHBAmzwRv7KArJGAAAAAAEAL//yAo8CwAAfAAABMxEjNQcGIyImNTQ2MzIWH +wEjJyYjIgYVFBYzMjY1IwGP90wfPnWTprSSf48LAVYCIqJofHllVG+hAU3+s3hARsicn8uAagoKpq +N/gaN1XAAAAAEAZAAAAowCsgALAAABESMRIREjETMRIRECjFb+hFZWAXwCsv1OAS7+0gKy/sQBPAA +AAAABAGQAAAC6ArIAAwAAMyMRM7pWVgKyAAABADf/8gHoArIAEwAAAREUBw4BIyImLwEzFxYzMjc2 +NREB6AIFcGpgbQIBVgIHfXQKAQKy/lYxIltob2EpKYyEFD0BpwAAAAABAGQAAAJ0ArIACwAACQEjA +wcVIxEzEQEzATsBJ3ntQlZWAVVlAWH+nwEnR+ACsv6RAW8AAQBkAAACLwKyAAUAACUVIREzEQIv/j +VWRkYCsv2UAAABAGQAAAMUArIAFAAAAREjETQ3BgcDIwMmJxYVESMRMxsBAxRWAiMxemx8NxsCVo7 +MywKy/U4BY7ZLco7+nAFmoFxLtP6dArL9lwJpAAAAAAEAZAAAAoACsgANAAAhIwEWFREjETMBJjUR +MwKAhP67A1aEAUUDVAJeeov+pwKy/aJ5jAFZAAAAAgAv//ICuwLAAAkAEwAAEiAWFRQGICY1NBIyN +jU0JiIGFRTbATSsrP7MrNrYenrYegLAxaKhxsahov47nIeIm5uIhwACAGQAAAJHArIADgAYAAABHg +EVFAYHBisBESMRMzITNjQnJisBETMyAZRUX2VOHzuAVtY7GlxcGDWIiDUCrgtnVlVpCgT+5gKy/rU +V1BUF/vgAAAACAC//zAK9AsAAEgAcAAAlFhcHJiMiBwYjIiY1NDYgFhUUJRQWMjY1NCYiBgI9PUMx +UDcfKh8omqysATSs/dR62Hp62HpICTg7NgkHxqGixcWitbWHnJyHiJubAAIAZAAAAlgCsgAXACMAA +CUWFyMmJyYnJisBESMRMzIXHgEVFAYHFiUzMjc+ATU0JyYrAQIqDCJfGQwNWhAhglbiOx9QXEY1Tv +6bhDATMj1lGSyMtYgtOXR0BwH+1wKyBApbU0BSESRAAgVAOGoQBAABADT/8gIoAsAAJQAAATIWFyM +uASMiBhUUFhceARUUBiMiJiczHgEzMjY1NCYnLgE1NDYBOmd2ClwGS0E6SUNRdW+HZnKKC1wPWkQ9 +Uk1cZGuEAsBwXUJHNjQ3OhIbZVZZbm5kREo+NT5DFRdYUFdrAAAAAAEAIgAAAmQCsgAHAAABIxEjE +SM1IQJk9lb2AkICbP2UAmxGAAEAXv/yAmQCsgAXAAABERQHDgEiJicmNREzERQXHgEyNjc2NRECZA +IIgfCBCAJWAgZYmlgGAgKy/k0qFFxzc1wUKgGz/lUrEkRQUEQSKwGrAAAAAAEAIAAAAnoCsgAGAAA +hIwMzGwEzAYJ07l3N1FwCsv2PAnEAAAEAGgAAA7ECsgAMAAABAyMLASMDMxsBMxsBA7HAcZyicrZi +kaB0nJkCsv1OAlP9rQKy/ZsCW/2kAmYAAAEAGQAAAm8CsgALAAAhCwEjEwMzGwEzAxMCCsrEY/bkY +re+Y/D6AST+3AFcAVb+5gEa/q3+oQAAAQATAAACUQKyAAgAAAERIxEDMxsBMwFdVvRjwLphARD+8A +EQAaL+sQFPAAABAC4AAAI5ArIACQAAJRUhNQEhNSEVAQI5/fUBof57Aen+YUZGQgIqRkX92QAAAAA +BAGL/sAEFAwwABwAAARUjETMVIxEBBWlpowMMOP0UOANcAAAB//v/4gE0AtAAAwAABSMDMwE0Pvs+ +HgLuAAAAAQAi/7AAxQMMAAcAABcjNTMRIzUzxaNpaaNQOALsOAABAFAA1wH0AmgABgAAJQsBIxMzE +wGwjY1GsESw1wFZ/qcBkf5vAAAAAQAy/6oBwv/iAAMAAAUhNSEBwv5wAZBWOAAAAAEAKQJEALYCsg +ADAAATIycztjhVUAJEbgAAAAACACT/8gHQAiAAHQAlAAAhJwcGIyImNTQ2OwE1NCcmIyIHIz4BMzI +XFh0BFBcnMjY9ASYVFAF6CR0wVUtgkJoiAgdgaQlaBm1Zrg4DCuQ9R+5MOSFQR1tbDiwUUXBUXowf +J8c9SjRORzYSgVwAAAAAAgBK//ICRQLfABEAHgAAATIWFRQGIyImLwEVIxEzETc2EzI2NTQmIyIGH +QEUFgFUcYCVbiNJEyNWVigySElcU01JXmECIJd4i5QTEDRJAt/+3jkq/hRuZV55ZWsdX14AAQAe// +IB9wIgABgAAAEyFhcjJiMiBhUUFjMyNjczDgEjIiY1NDYBF152DFocbEJXU0A1Rw1aE3pbaoKQAiB +oWH5qZm1tPDlaXYuLgZcAAAACAB7/8gIZAt8AEQAeAAABESM1BwYjIiY1NDYzMhYfAREDMjY9ATQm +IyIGFRQWAhlWKDJacYCVbiNJEyOnSV5hQUlcUwLf/SFVOSqXeIuUExA0ARb9VWVrHV9ebmVeeQACA +B7/8gH9AiAAFQAbAAABFAchHgEzMjY3Mw4BIyImNTQ2MzIWJyIGByEmAf0C/oAGUkA1SwlaD4FXbI +WObmt45UBVBwEqDQEYFhNjWD84W16Oh3+akU9aU60AAAEAFQAAARoC8gAWAAATBh0BMxUjESMRIzU +zNTQ3PgEzMhcVJqcDbW1WOTkDB0k8Hx5oAngVITRC/jQBzEIsJRs5PwVHEwAAAAIAHv8uAhkCIAAi +AC8AAAERFAcOASMiLwEzFx4BMzI2NzY9AQcGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZAQSEd +NwRAVcBBU5DTlUDASgyWnGAlW4jSRMjp0leYUFJXFMCEv5wSh1zeq8KCTI8VU0ZIQk5Kpd4i5QTED +RJ/iJlax1fXm5lXnkAAQBKAAACCgLkABcAAAEWFREjETQnLgEHDgEdASMRMxE3NjMyFgIIAlYCBDs +6RVRWViE5UVViAYUbQP7WASQxGzI7AQJyf+kC5P7TPSxUAAACAD4AAACsAsAABwALAAASMhYUBiIm +NBMjETNeLiAgLiBiVlYCwCAuICAu/WACEgAC//P/LgCnAsAABwAVAAASMhYUBiImNBcRFAcGIyInN +RY3NjURWS4gIC4gYgMLcRwNSgYCAsAgLiAgLo79wCUbZAJGBzMOHgJEAAAAAQBKAAACCALfAAsAAC +EnBxUjETMREzMHEwGTwTJWVvdu9/rgN6kC3/4oAQv6/ugAAQBG//wA3gLfAA8AABMRFBceATcVBiM +iJicmNRGcAQIcIxkkKi4CAQLf/bkhERoSBD4EJC8SNAJKAAAAAQBKAAADEAIgACQAAAEWFREjETQn +JiMiFREjETQnJiMiFREjETMVNzYzMhYXNzYzMhYDCwVWBAxedFYEDF50VlYiJko7ThAvJkpEVAGfI +jn+vAEcQyRZ1v76ARxDJFnW/voCEk08HzYtRB9HAAAAAAEASgAAAgoCIAAWAAABFhURIxE0JyYjIg +YdASMRMxU3NjMyFgIIAlYCCXBEVVZWITlRVWIBhRtA/tYBJDEbbHR/6QISWz0sVAAAAAACAB7/8gI +sAiAABwARAAASIBYUBiAmNBIyNjU0JiIGFRSlAQCHh/8Ah7ieWlqeWgIgn/Cfn/D+s3ZfYHV1YF8A +AgBK/zwCRQIgABEAHgAAATIWFRQGIyImLwERIxEzFTc2EzI2NTQmIyIGHQEUFgFUcYCVbiNJEyNWV +igySElcU01JXmECIJd4i5QTEDT+8wLWVTkq/hRuZV55ZWsdX14AAgAe/zwCGQIgABEAHgAAAREjEQ +cGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZVigyWnGAlW4jSRMjp0leYUFJXFMCEv0qARk5Kpd +4i5QTEDRJ/iJlax1fXm5lXnkAAQBKAAABPgIeAA0AAAEyFxUmBhURIxEzFTc2ARoWDkdXVlYwIwIe +B0EFVlf+0gISU0cYAAEAGP/yAa0CIAAjAAATMhYXIyYjIgYVFBYXHgEVFAYjIiYnMxYzMjY1NCYnL +gE1NDbkV2MJWhNdKy04PF1XbVhWbgxaE2ktOjlEUllkAiBaS2MrJCUoEBlPQkhOVFZoKCUmLhIWSE +BIUwAAAAEAFP/4ARQCiQAXAAATERQXHgE3FQYjIiYnJjURIzUzNTMVMxWxAQMmMx8qMjMEAUdHVmM +BzP7PGw4mFgY/BSwxDjQBNUJ7e0IAAAABAEL/8gICAhIAFwAAAREjNQcGIyImJyY1ETMRFBceATMy +Nj0BAgJWITlRT2EKBVYEBkA1RFECEv3uWj4qTToiOQE+/tIlJC43c4DpAAAAAAEAAQAAAfwCEgAGA +AABAyMDMxsBAfzJaclfop8CEv3uAhL+LQHTAAABAAEAAAMLAhIADAAAAQMjCwEjAzMbATMbAQMLqW +Z2dmapY3t0a3Z7AhL97gG+/kICEv5AAcD+QwG9AAAB//oAAAHWAhIACwAAARMjJwcjEwMzFzczARq +8ZIuKY763ZoWFYwEO/vLV1QEMAQbNzQAAAQAB/y4B+wISABEAAAEDDgEjIic1FjMyNj8BAzMbAQH7 +2iFZQB8NDRIpNhQH02GenQIS/cFVUAJGASozEwIt/i4B0gABABQAAAGxAg4ACQAAJRUhNQEhNSEVA +QGx/mMBNP7iAYL+zkREQgGIREX+ewAAAAABAED/sAEOAwwALAAAASMiBhUUFxYVFAYHHgEVFAcGFR +QWOwEVIyImNTQ3NjU0JzU2NTQnJjU0NjsBAQ4MKiMLDS4pKS4NCyMqDAtERAwLUlILDERECwLUGBk +WTlsgKzUFBTcrIFtOFhkYOC87GFVMIkUIOAhFIkxVGDsvAAAAAAEAYP84AJoDIAADAAAXIxEzmjo6 +yAPoAAEAIf+wAO8DDAAsAAATFQYVFBcWFRQGKwE1MzI2NTQnJjU0NjcuATU0NzY1NCYrATUzMhYVF +AcGFRTvUgsMREQLDCojCw0uKSkuDQsjKgwLREQMCwF6OAhFIkxVGDsvOBgZFk5bICs1BQU3KyBbTh +YZGDgvOxhVTCJFAAABAE0A3wH2AWQAEwAAATMUIyImJyYjIhUjNDMyFhcWMzIBvjhuGywtQR0xOG4 +bLC1BHTEBZIURGCNMhREYIwAAAwAk/94DIgLoAAcAEQApAAAAIBYQBiAmECQgBhUUFiA2NTQlMhYX +IyYjIgYUFjMyNjczDgEjIiY1NDYBAQFE3d3+vN0CB/7wubkBELn+xVBnD1wSWDo+QTcqOQZcEmZWX +HN2Aujg/rbg4AFKpr+Mjb6+jYxbWEldV5ZZNShLVn5na34AAgB4AFIB9AGeAAUACwAAAQcXIyc3Mw +cXIyc3AUqJiUmJifOJiUmJiQGepqampqampqYAAAIAHAHSAQ4CwAAHAA8AABIyFhQGIiY0NiIGFBY +yNjRgakREakSTNCEhNCECwEJqQkJqCiM4IyM4AAAAAAIAUAAAAfQCCwALAA8AAAEzFSMVIzUjNTM1 +MxMhNSEBP7W1OrW1OrX+XAGkAVs4tLQ4sP31OAAAAQB0AkQBAQKyAAMAABMjNzOsOD1QAkRuAAAAA +AEAIADsAKoBdgAHAAASMhYUBiImNEg6KCg6KAF2KDooKDoAAAIAOQBSAbUBngAFAAsAACUHIzcnMw +UHIzcnMwELiUmJiUkBM4lJiYlJ+KampqampqYAAAABADYB5QDhAt8ABAAAEzczByM2Xk1OXQHv8Po +AAQAWAeUAwQLfAAQAABMHIzczwV5NTl0C1fD6AAIANgHlAYsC3wAEAAkAABM3MwcjPwEzByM2Xk1O +XapeTU5dAe/w+grw+gAAAgAWAeUBawLfAAQACQAAEwcjNzMXByM3M8FeTU5dql5NTl0C1fD6CvD6A +AADACX/8gI1AHIABwAPABcAADYyFhQGIiY0NjIWFAYiJjQ2MhYUBiImNEk4JCQ4JOw4JCQ4JOw4JC +Q4JHIkOCQkOCQkOCQkOCQkOCQkOAAAAAEAeABSAUoBngAFAAABBxcjJzcBSomJSYmJAZ6mpqamAAA +AAAEAOQBSAQsBngAFAAAlByM3JzMBC4lJiYlJ+KampgAAAf9qAAABgQKyAAMAACsBATM/VwHAVwKy +AAAAAAIAFAHIAdwClAAHABQAABMVIxUjNSM1BRUjNwcjJxcjNTMXN9pKMkoByDICKzQqATJLKysCl +CmjoykBy46KiY3Lm5sAAQAVAAABvALyABgAAAERIxEjESMRIzUzNTQ3NjMyFxUmBgcGHQEBvFbCVj +k5AxHHHx5iVgcDAg798gHM/jQBzEIOJRuWBUcIJDAVIRYAAAABABX//AHkAvIAJQAAJR4BNxUGIyI +mJyY1ESYjIgcGHQEzFSMRIxEjNTM1NDc2MzIXERQBowIcIxkkKi4CAR4nXgwDbW1WLy8DEbNdOmYa +EQQ/BCQvEjQCFQZWFSEWQv40AcxCDiUblhP9uSEAAAAAAAAWAQ4AAQAAAAAAAAATACgAAQAAAAAAA +QAHAEwAAQAAAAAAAgAHAGQAAQAAAAAAAwAaAKIAAQAAAAAABAAHAM0AAQAAAAAABQA8AU8AAQAAAA +AABgAPAawAAQAAAAAACAALAdQAAQAAAAAACQALAfgAAQAAAAAACwAXAjQAAQAAAAAADAAXAnwAAwA +BBAkAAAAmAAAAAwABBAkAAQAOADwAAwABBAkAAgAOAFQAAwABBAkAAwA0AGwAAwABBAkABAAOAL0A +AwABBAkABQB4ANUAAwABBAkABgAeAYwAAwABBAkACAAWAbwAAwABBAkACQAWAeAAAwABBAkACwAuA +gQAAwABBAkADAAuAkwATgBvACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAATm8gUm +lnaHRzIFJlc2VydmVkLgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAUgBlAGcAdQBsAGEAcgAAUmV +ndWxhcgAAMQAuADEAMAAyADsAVQBLAFcATgA7AEEAaQBsAGUAcgBvAG4ALQBSAGUAZwB1AGwAYQBy +AAAxLjEwMjtVS1dOO0FpbGVyb24tUmVndWxhcgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAVgBlA +HIAcwBpAG8AbgAgADEALgAxADAAMgA7AFAAUwAgADAAMAAxAC4AMQAwADIAOwBoAG8AdABjAG8Abg +B2ACAAMQAuADAALgA3ADAAOwBtAGEAawBlAG8AdABmAC4AbABpAGIAMgAuADUALgA1ADgAMwAyADk +AAFZlcnNpb24gMS4xMDI7UFMgMDAxLjEwMjtob3Rjb252IDEuMC43MDttYWtlb3RmLmxpYjIuNS41 +ODMyOQAAQQBpAGwAZQByAG8AbgAtAFIAZQBnAHUAbABhAHIAAEFpbGVyb24tUmVndWxhcgAAUwBvA +HIAYQAgAFMAYQBnAGEAbgBvAABTb3JhIFNhZ2FubwAAUwBvAHIAYQAgAFMAYQBnAGEAbgBvAABTb3 +JhIFNhZ2FubwAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBsAG8AbgAuAG4AZQB0AAB +odHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBs +AG8AbgAuAG4AZQB0AABodHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAAAACAAAAAAAA/4MAMgAAAAAAA +AAAAAAAAAAAAAAAAAAAAHQAAAABAAIAAwAEAAUABgAHAAgACQAKAAsADAANAA4ADwAQABEAEgATAB +QAFQAWABcAGAAZABoAGwAcAB0AHgAfACAAIQAiACMAJAAlACYAJwAoACkAKgArACwALQAuAC8AMAA +xADIAMwA0ADUANgA3ADgAOQA6ADsAPAA9AD4APwBAAEEAQgBDAEQARQBGAEcASABJAEoASwBMAE0A +TgBPAFAAUQBSAFMAVABVAFYAVwBYAFkAWgBbAFwAXQBeAF8AYABhAIsAqQCDAJMAjQDDAKoAtgC3A +LQAtQCrAL4AvwC8AIwAwADBAAAAAAAB//8AAgABAAAADAAAABwAAAACAAIAAwBxAAEAcgBzAAIABA +AAAAIAAAABAAAACgBMAGYAAkRGTFQADmxhdG4AGgAEAAAAAP//AAEAAAAWAANDQVQgAB5NT0wgABZ +ST00gABYAAP//AAEAAAAA//8AAgAAAAEAAmxpZ2EADmxvY2wAFAAAAAEAAQAAAAEAAAACAAYAEAAG +AAAAAgASADQABAAAAAEATAADAAAAAgAQABYAAQAcAAAAAQABAE8AAQABAGcAAQABAE8AAwAAAAIAE +AAWAAEAHAAAAAEAAQAvAAEAAQBnAAEAAQAvAAEAGgABAAgAAgAGAAwAcwACAE8AcgACAEwAAQABAE +kAAAABAAAACgBGAGAAAkRGTFQADmxhdG4AHAAEAAAAAP//AAIAAAABABYAA0NBVCAAFk1PTCAAFlJ +PTSAAFgAA//8AAgAAAAEAAmNwc3AADmtlcm4AFAAAAAEAAAAAAAEAAQACAAYADgABAAAAAQASAAIA +AAACAB4ANgABAAoABQAFAAoAAgABACQAPQAAAAEAEgAEAAAAAQAMAAEAOP/nAAEAAQAkAAIGigAEA +AAFJAXKABoAGQAA//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAD/sv+4/+z/7v/MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAD/xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9T/6AAAAAD/8QAA +ABD/vQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/7gAAAAAAAAAAAAAAAAAA//MAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIAAAAAAAAAAP/5AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/gAAD/4AAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//L/9AAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAA/+gAAAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/mAAAAAAAAAAAAAAAAAAD +/4gAA//AAAAAA//YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+AAAAAAAAP/OAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/zv/qAAAAAP/0AAAACAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/ZAAD/egAA/1kAAAAA/5D/rgAAAAAAAAAAAA +AAAAAAAAAAAAAAAAD/9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAD/8AAA/7b/8P+wAAD/8P/E/98AAAAA/8P/+P/0//oAAAAAAAAAAAAA//gA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+AAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/w//C/9MAAP/SAAD/9wAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAD/yAAA/+kAAAAA//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/9wAAAAD//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAP/2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAP/cAAAAAAAAAAAAAAAA/7YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAP/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/6AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAkAFAAEAAAAAQACwAAABcA +BgAAAAAAAAAIAA4AAAAAAAsAEgAAAAAAAAATABkAAwANAAAAAQAJAAAAAAAAAAAAAAAAAAAAGAAAA +AAABwAAAAAAAAAAAAAAFQAFAAAAAAAYABgAAAAUAAAACgAAAAwAAgAPABEAFgAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAEAEQBdAAYAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAcAAAAAAAAABwAAAAAACAAAAAAAAAAAAAcAAAAHAAAAEwAJ +ABUADgAPAAAACwAQAAAAAAAAAAAAAAAAAAUAGAACAAIAAgAAAAIAGAAXAAAAGAAAABYAFgACABYAA +gAWAAAAEQADAAoAFAAMAA0ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAEgAGAAEAHgAkAC +YAJwApACoALQAuAC8AMgAzADcAOAA5ADoAPAA9AEUASABOAE8AUgBTAFUAVwBZAFoAWwBcAF0AcwA +AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== +""" + ) + ), + 10 if size is None else size, + layout_engine=Layout.BASIC, + ) + return load_default_imagefont() diff --git a/dependencies/OSX/PIL/ImageGrab.py b/dependencies/OSX/PIL/ImageGrab.py index b93ec3f..fe27bfa 100644 --- a/dependencies/OSX/PIL/ImageGrab.py +++ b/dependencies/OSX/PIL/ImageGrab.py @@ -14,30 +14,41 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import io +import os +import shutil +import subprocess import sys +import tempfile from . import Image -if sys.platform == "darwin": - import os - import subprocess - import tempfile - -def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): +def grab( + bbox: tuple[int, int, int, int] | None = None, + include_layered_windows: bool = False, + all_screens: bool = False, + xdisplay: str | None = None, +) -> Image.Image: + im: Image.Image if xdisplay is None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") os.close(fh) - subprocess.call(["screencapture", "-x", filepath]) + args = ["screencapture"] + if bbox: + left, top, right, bottom = bbox + args += ["-R", f"{left},{top},{right-left},{bottom-top}"] + subprocess.call(args + ["-x", filepath]) im = Image.open(filepath) im.load() os.unlink(filepath) if bbox: - im_cropped = im.crop(bbox) + im_resized = im.resize((right - left, bottom - top)) im.close() - return im_cropped + return im_resized return im elif sys.platform == "win32": offset, size, data = Image.core.grabscreen_win32( @@ -58,40 +69,52 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im - # use xdisplay=None for default display on non-win32/macOS systems - if not Image.core.HAVE_XCB: - raise OSError("Pillow was built without XCB support") - size, data = Image.core.grabscreen_x11(xdisplay) - im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) - if bbox: - im = im.crop(bbox) - return im + # Cast to Optional[str] needed for Windows and macOS. + display_name: str | None = xdisplay + try: + if not Image.core.HAVE_XCB: + msg = "Pillow was built without XCB support" + raise OSError(msg) + size, data = Image.core.grabscreen_x11(display_name) + except OSError: + if ( + display_name is None + and sys.platform not in ("darwin", "win32") + and shutil.which("gnome-screenshot") + ): + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + subprocess.call(["gnome-screenshot", "-f", filepath]) + im = Image.open(filepath) + im.load() + os.unlink(filepath) + if bbox: + im_cropped = im.crop(bbox) + im.close() + return im_cropped + return im + else: + raise + else: + im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) + if bbox: + im = im.crop(bbox) + return im -def grabclipboard(): +def grabclipboard() -> Image.Image | list[str] | None: if sys.platform == "darwin": - fh, filepath = tempfile.mkstemp(".jpg") - os.close(fh) - commands = [ - 'set theFile to (open for access POSIX file "' - + filepath - + '" with write permission)', - "try", - " write (the clipboard as JPEG picture) to theFile", - "end try", - "close access theFile", - ] - script = ["osascript"] - for command in commands: - script += ["-e", command] - subprocess.call(script) + p = subprocess.run( + ["osascript", "-e", "get the clipboard as «class PNGf»"], + capture_output=True, + ) + if p.returncode != 0: + return None - im = None - if os.stat(filepath).st_size != 0: - im = Image.open(filepath) - im.load() - os.unlink(filepath) - return im + import binascii + + data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3])) + return Image.open(data) elif sys.platform == "win32": fmt, data = Image.core.grabclipboard_win32() if fmt == "file": # CF_HDROP @@ -104,8 +127,6 @@ def grabclipboard(): files = data[o:].decode("mbcs").split("\0") return files[: files.index("")] if isinstance(data, bytes): - import io - data = io.BytesIO(data) if fmt == "png": from . import PngImagePlugin @@ -117,4 +138,46 @@ def grabclipboard(): return BmpImagePlugin.DibImageFile(data) return None else: - raise NotImplementedError("ImageGrab.grabclipboard() is macOS and Windows only") + if os.getenv("WAYLAND_DISPLAY"): + session_type = "wayland" + elif os.getenv("DISPLAY"): + session_type = "x11" + else: # Session type check failed + session_type = None + + if shutil.which("wl-paste") and session_type in ("wayland", None): + args = ["wl-paste", "-t", "image"] + elif shutil.which("xclip") and session_type in ("x11", None): + args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] + else: + msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux" + raise NotImplementedError(msg) + + p = subprocess.run(args, capture_output=True) + if p.returncode != 0: + err = p.stderr + for silent_error in [ + # wl-paste, when the clipboard is empty + b"Nothing is copied", + # Ubuntu/Debian wl-paste, when the clipboard is empty + b"No selection", + # Ubuntu/Debian wl-paste, when an image isn't available + b"No suitable type of content copied", + # wl-paste or Ubuntu/Debian xclip, when an image isn't available + b" not available", + # xclip, when an image isn't available + b"cannot convert ", + # xclip, when the clipboard isn't initialized + b"xclip: Error: There is no owner for the ", + ]: + if silent_error in err: + return None + msg = f"{args[0]} error" + if err: + msg += f": {err.strip().decode()}" + raise ChildProcessError(msg) + + data = io.BytesIO(p.stdout) + im = Image.open(data) + im.load() + return im diff --git a/dependencies/OSX/PIL/ImageMath.py b/dependencies/OSX/PIL/ImageMath.py index 7f9c88e..484797f 100644 --- a/dependencies/OSX/PIL/ImageMath.py +++ b/dependencies/OSX/PIL/ImageMath.py @@ -14,25 +14,23 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import builtins +from types import CodeType +from typing import Any, Callable from . import Image, _imagingmath - -VERBOSE = 0 - - -def _isconstant(v): - return isinstance(v, (int, float)) +from ._deprecate import deprecate class _Operand: """Wraps an image operand, providing standard operators""" - def __init__(self, im): + def __init__(self, im: Image.Image): self.im = im - def __fixup(self, im1): + def __fixup(self, im1: _Operand | float) -> Image.Image: # convert image to suitable mode if isinstance(im1, _Operand): # argument was an image. @@ -41,213 +39,330 @@ def __fixup(self, im1): elif im1.im.mode in ("I", "F"): return im1.im else: - raise ValueError(f"unsupported mode: {im1.im.mode}") + msg = f"unsupported mode: {im1.im.mode}" + raise ValueError(msg) else: # argument was a constant - if _isconstant(im1) and self.im.mode in ("1", "L", "I"): + if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"): return Image.new("I", self.im.size, im1) else: return Image.new("F", self.im.size, im1) - def apply(self, op, im1, im2=None, mode=None): - im1 = self.__fixup(im1) + def apply( + self, + op: str, + im1: _Operand | float, + im2: _Operand | float | None = None, + mode: str | None = None, + ) -> _Operand: + im_1 = self.__fixup(im1) if im2 is None: # unary operation - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() + out = Image.new(mode or im_1.mode, im_1.size, None) try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e - _imagingmath.unop(op, out.im.id, im1.im.id) + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.unop(op, out.getim(), im_1.getim()) else: # binary operation - im2 = self.__fixup(im2) - if im1.mode != im2.mode: + im_2 = self.__fixup(im2) + if im_1.mode != im_2.mode: # convert both arguments to floating point - if im1.mode != "F": - im1 = im1.convert("F") - if im2.mode != "F": - im2 = im2.convert("F") - if im1.mode != im2.mode: - raise ValueError("mode mismatch") - if im1.size != im2.size: + if im_1.mode != "F": + im_1 = im_1.convert("F") + if im_2.mode != "F": + im_2 = im_2.convert("F") + if im_1.size != im_2.size: # crop both arguments to a common size - size = (min(im1.size[0], im2.size[0]), min(im1.size[1], im2.size[1])) - if im1.size != size: - im1 = im1.crop((0, 0) + size) - if im2.size != size: - im2 = im2.crop((0, 0) + size) - out = Image.new(mode or im1.mode, size, None) - else: - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() - im2.load() + size = ( + min(im_1.size[0], im_2.size[0]), + min(im_1.size[1], im_2.size[1]), + ) + if im_1.size != size: + im_1 = im_1.crop((0, 0) + size) + if im_2.size != size: + im_2 = im_2.crop((0, 0) + size) + out = Image.new(mode or im_1.mode, im_1.size, None) try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e - _imagingmath.binop(op, out.im.id, im1.im.id, im2.im.id) + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim()) return _Operand(out) # unary operators - def __bool__(self): + def __bool__(self) -> bool: # an image is "true" if it contains at least one non-zero pixel return self.im.getbbox() is not None - def __abs__(self): + def __abs__(self) -> _Operand: return self.apply("abs", self) - def __pos__(self): + def __pos__(self) -> _Operand: return self - def __neg__(self): + def __neg__(self) -> _Operand: return self.apply("neg", self) # binary operators - def __add__(self, other): + def __add__(self, other: _Operand | float) -> _Operand: return self.apply("add", self, other) - def __radd__(self, other): + def __radd__(self, other: _Operand | float) -> _Operand: return self.apply("add", other, self) - def __sub__(self, other): + def __sub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", self, other) - def __rsub__(self, other): + def __rsub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", other, self) - def __mul__(self, other): + def __mul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", self, other) - def __rmul__(self, other): + def __rmul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", other, self) - def __truediv__(self, other): + def __truediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", self, other) - def __rtruediv__(self, other): + def __rtruediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", other, self) - def __mod__(self, other): + def __mod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", self, other) - def __rmod__(self, other): + def __rmod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", other, self) - def __pow__(self, other): + def __pow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", self, other) - def __rpow__(self, other): + def __rpow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", other, self) # bitwise - def __invert__(self): + def __invert__(self) -> _Operand: return self.apply("invert", self) - def __and__(self, other): + def __and__(self, other: _Operand | float) -> _Operand: return self.apply("and", self, other) - def __rand__(self, other): + def __rand__(self, other: _Operand | float) -> _Operand: return self.apply("and", other, self) - def __or__(self, other): + def __or__(self, other: _Operand | float) -> _Operand: return self.apply("or", self, other) - def __ror__(self, other): + def __ror__(self, other: _Operand | float) -> _Operand: return self.apply("or", other, self) - def __xor__(self, other): + def __xor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", self, other) - def __rxor__(self, other): + def __rxor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", other, self) - def __lshift__(self, other): + def __lshift__(self, other: _Operand | float) -> _Operand: return self.apply("lshift", self, other) - def __rshift__(self, other): + def __rshift__(self, other: _Operand | float) -> _Operand: return self.apply("rshift", self, other) # logical - def __eq__(self, other): + def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("eq", self, other) - def __ne__(self, other): + def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("ne", self, other) - def __lt__(self, other): + def __lt__(self, other: _Operand | float) -> _Operand: return self.apply("lt", self, other) - def __le__(self, other): + def __le__(self, other: _Operand | float) -> _Operand: return self.apply("le", self, other) - def __gt__(self, other): + def __gt__(self, other: _Operand | float) -> _Operand: return self.apply("gt", self, other) - def __ge__(self, other): + def __ge__(self, other: _Operand | float) -> _Operand: return self.apply("ge", self, other) # conversions -def imagemath_int(self): +def imagemath_int(self: _Operand) -> _Operand: return _Operand(self.im.convert("I")) -def imagemath_float(self): +def imagemath_float(self: _Operand) -> _Operand: return _Operand(self.im.convert("F")) # logical -def imagemath_equal(self, other): +def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("eq", self, other, mode="I") -def imagemath_notequal(self, other): +def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("ne", self, other, mode="I") -def imagemath_min(self, other): +def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("min", self, other) -def imagemath_max(self, other): +def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("max", self, other) -def imagemath_convert(self, mode): +def imagemath_convert(self: _Operand, mode: str) -> _Operand: return _Operand(self.im.convert(mode)) -ops = {} -for k, v in list(globals().items()): - if k[:10] == "imagemath_": - ops[k[10:]] = v +ops = { + "int": imagemath_int, + "float": imagemath_float, + "equal": imagemath_equal, + "notequal": imagemath_notequal, + "min": imagemath_min, + "max": imagemath_max, + "convert": imagemath_convert, +} -def eval(expression, _dict={}, **kw): +def lambda_eval( + expression: Callable[[dict[str, Any]], Any], + options: dict[str, Any] = {}, + **kw: Any, +) -> Any: """ - Evaluates an image expression. + Returns the result of an image function. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. + + :param expression: A function that receives a dictionary. + :param options: Values to add to the function's dictionary. Deprecated. + You can instead use one or more keyword arguments. + :param **kw: Values to add to the function's dictionary. + :return: The expression result. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + """ + + if options: + deprecate( + "ImageMath.lambda_eval options", + 12, + "ImageMath.lambda_eval keyword arguments", + ) + + args: dict[str, Any] = ops.copy() + args.update(options) + args.update(kw) + for k, v in args.items(): + if isinstance(v, Image.Image): + args[k] = _Operand(v) + + out = expression(args) + try: + return out.im + except AttributeError: + return out + + +def unsafe_eval( + expression: str, + options: dict[str, Any] = {}, + **kw: Any, +) -> Any: + """ + Evaluates an image expression. This uses Python's ``eval()`` function to process + the expression string, and carries the security risks of doing so. It is not + recommended to process expressions without considering this. + :py:meth:`~lambda_eval` is a more secure alternative. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. :param expression: A string containing a Python-style expression. - :param options: Values to add to the evaluation context. You - can either use a dictionary, or one or more keyword - arguments. + :param options: Values to add to the evaluation context. Deprecated. + You can instead use one or more keyword arguments. + :param **kw: Values to add to the evaluation context. :return: The evaluated expression. This is usually an image object, but can also be an integer, a floating point value, or a pixel tuple, depending on the expression. """ + if options: + deprecate( + "ImageMath.unsafe_eval options", + 12, + "ImageMath.unsafe_eval keyword arguments", + ) + # build execution namespace - args = ops.copy() - args.update(_dict) + args: dict[str, Any] = ops.copy() + for k in list(options.keys()) + list(kw.keys()): + if "__" in k or hasattr(builtins, k): + msg = f"'{k}' not allowed" + raise ValueError(msg) + + args.update(options) args.update(kw) - for k, v in list(args.items()): - if hasattr(v, "im"): + for k, v in args.items(): + if isinstance(v, Image.Image): args[k] = _Operand(v) - out = builtins.eval(expression, args) + compiled_code = compile(expression, "", "eval") + + def scan(code: CodeType) -> None: + for const in code.co_consts: + if type(const) is type(compiled_code): + scan(const) + + for name in code.co_names: + if name not in args and name != "abs": + msg = f"'{name}' not allowed" + raise ValueError(msg) + + scan(compiled_code) + out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args) try: return out.im except AttributeError: return out + + +def eval( + expression: str, + _dict: dict[str, Any] = {}, + **kw: Any, +) -> Any: + """ + Evaluates an image expression. + + Deprecated. Use lambda_eval() or unsafe_eval() instead. + + :param expression: A string containing a Python-style expression. + :param _dict: Values to add to the evaluation context. You + can either use a dictionary, or one or more keyword + arguments. + :return: The evaluated expression. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + + .. deprecated:: 10.3.0 + """ + + deprecate( + "ImageMath.eval", + 12, + "ImageMath.lambda_eval or ImageMath.unsafe_eval", + ) + return unsafe_eval(expression, _dict, **kw) diff --git a/dependencies/OSX/PIL/ImageMode.py b/dependencies/OSX/PIL/ImageMode.py index 9882883..92a08d2 100644 --- a/dependencies/OSX/PIL/ImageMode.py +++ b/dependencies/OSX/PIL/ImageMode.py @@ -12,53 +12,81 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations -# mode descriptor cache -_modes = None +import sys +from functools import lru_cache +from typing import NamedTuple +from ._deprecate import deprecate -class ModeDescriptor: + +class ModeDescriptor(NamedTuple): """Wrapper for mode strings.""" - def __init__(self, mode, bands, basemode, basetype): - self.mode = mode - self.bands = bands - self.basemode = basemode - self.basetype = basetype + mode: str + bands: tuple[str, ...] + basemode: str + basetype: str + typestr: str - def __str__(self): + def __str__(self) -> str: return self.mode -def getmode(mode): +@lru_cache +def getmode(mode: str) -> ModeDescriptor: """Gets a mode descriptor for the given mode.""" - global _modes - if not _modes: - # initialize mode cache - - from . import Image + endian = "<" if sys.byteorder == "little" else ">" - modes = {} + modes = { # core modes - for m, (basemode, basetype, bands) in Image._MODEINFO.items(): - modes[m] = ModeDescriptor(m, bands, basemode, basetype) + # Bits need to be extended to bytes + "1": ("L", "L", ("1",), "|b1"), + "L": ("L", "L", ("L",), "|u1"), + "I": ("L", "I", ("I",), f"{endian}i4"), + "F": ("L", "F", ("F",), f"{endian}f4"), + "P": ("P", "L", ("P",), "|u1"), + "RGB": ("RGB", "L", ("R", "G", "B"), "|u1"), + "RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"), + "RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"), + "CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"), + "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"), + # UNDONE - unsigned |u1i1i1 + "LAB": ("RGB", "L", ("L", "A", "B"), "|u1"), + "HSV": ("RGB", "L", ("H", "S", "V"), "|u1"), # extra experimental modes - modes["RGBa"] = ModeDescriptor("RGBa", ("R", "G", "B", "a"), "RGB", "L") - modes["LA"] = ModeDescriptor("LA", ("L", "A"), "L", "L") - modes["La"] = ModeDescriptor("La", ("L", "a"), "L", "L") - modes["PA"] = ModeDescriptor("PA", ("P", "A"), "RGB", "L") - # mapping modes - for i16mode in ( - "I;16", - "I;16S", - "I;16L", - "I;16LS", - "I;16B", - "I;16BS", - "I;16N", - "I;16NS", - ): - modes[i16mode] = ModeDescriptor(i16mode, ("I",), "L", "L") - # set global mode cache atomically - _modes = modes - return _modes[mode] + "RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"), + "BGR;15": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;16": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;24": ("RGB", "L", ("B", "G", "R"), "|u1"), + "LA": ("L", "L", ("L", "A"), "|u1"), + "La": ("L", "L", ("L", "a"), "|u1"), + "PA": ("RGB", "L", ("P", "A"), "|u1"), + } + if mode in modes: + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + base_mode, base_type, bands, type_str = modes[mode] + return ModeDescriptor(mode, bands, base_mode, base_type, type_str) + + mapping_modes = { + # I;16 == I;16L, and I;32 == I;32L + "I;16": "u2", + "I;16BS": ">i2", + "I;16N": f"{endian}u2", + "I;16NS": f"{endian}i2", + "I;32": "u4", + "I;32L": "i4", + "I;32LS": " +from __future__ import annotations import re @@ -61,12 +62,14 @@ class LutBuilder: """ - def __init__(self, patterns=None, op_name=None): + def __init__( + self, patterns: list[str] | None = None, op_name: str | None = None + ) -> None: if patterns is not None: self.patterns = patterns else: self.patterns = [] - self.lut = None + self.lut: bytearray | None = None if op_name is not None: known_patterns = { "corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"], @@ -81,29 +84,32 @@ def __init__(self, patterns=None, op_name=None): ], } if op_name not in known_patterns: - raise Exception("Unknown pattern " + op_name + "!") + msg = f"Unknown pattern {op_name}!" + raise Exception(msg) self.patterns = known_patterns[op_name] - def add_patterns(self, patterns): + def add_patterns(self, patterns: list[str]) -> None: self.patterns += patterns - def build_default_lut(self): + def build_default_lut(self) -> None: symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) - def get_lut(self): + def get_lut(self) -> bytearray | None: return self.lut - def _string_permute(self, pattern, permutation): + def _string_permute(self, pattern: str, permutation: list[int]) -> str: """string_permute takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 return "".join(pattern[p] for p in permutation) - def _pattern_permute(self, basic_pattern, options, basic_result): + def _pattern_permute( + self, basic_pattern: str, options: str, basic_result: int + ) -> list[tuple[str, int]]: """pattern_permute takes a basic pattern and its result and clones the pattern according to the modifications described in the $options parameter. It returns a list of all cloned patterns.""" @@ -119,13 +125,13 @@ def _pattern_permute(self, basic_pattern, options, basic_result): # mirror if "M" in options: n = len(patterns) - for pattern, res in patterns[0:n]: + for pattern, res in patterns[:n]: patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res)) # negate if "N" in options: n = len(patterns) - for pattern, res in patterns[0:n]: + for pattern, res in patterns[:n]: # Swap 0 and 1 pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1") res = 1 - int(res) @@ -133,19 +139,21 @@ def _pattern_permute(self, basic_pattern, options, basic_result): return patterns - def build_lut(self): + def build_lut(self) -> bytearray: """Compile all patterns into a morphology lut. TBD :Build based on (file) morphlut:modify_lut """ self.build_default_lut() + assert self.lut is not None patterns = [] # Parse and create symmetries of the patterns strings for p in self.patterns: m = re.search(r"(\w*):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", "")) if not m: - raise Exception('Syntax error in pattern "' + p + '"') + msg = 'Syntax error in pattern "' + p + '"' + raise Exception(msg) options = m.group(1) pattern = m.group(2) result = int(m.group(3)) @@ -156,10 +164,10 @@ def build_lut(self): patterns += self._pattern_permute(pattern, options, result) # compile the patterns into regular expressions for speed - for i, pattern in enumerate(patterns): + compiled_patterns = [] + for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") - p = re.compile(p) - patterns[i] = (p, pattern[1]) + compiled_patterns.append((re.compile(p), pattern[1])) # Step through table and find patterns that match. # Note that all the patterns are searched. The last one @@ -169,8 +177,8 @@ def build_lut(self): bitpattern = bin(i)[2:] bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1] - for p, r in patterns: - if p.match(bitpattern): + for pattern, r in compiled_patterns: + if pattern.match(bitpattern): self.lut[i] = [0, 1][r] return self.lut @@ -179,7 +187,12 @@ def build_lut(self): class MorphOp: """A class for binary morphological operators""" - def __init__(self, lut=None, op_name=None, patterns=None): + def __init__( + self, + lut: bytearray | None = None, + op_name: str | None = None, + patterns: list[str] | None = None, + ) -> None: """Create a binary morphological operator""" self.lut = lut if op_name is not None: @@ -187,59 +200,66 @@ def __init__(self, lut=None, op_name=None, patterns=None): elif patterns is not None: self.lut = LutBuilder(patterns=patterns).build_lut() - def apply(self, image): + def apply(self, image: Image.Image) -> tuple[int, Image.Image]: """Run a single morphological operation on an image Returns a tuple of the number of changed pixels and the morphed image""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise Exception("Image must be binary, meaning it must use mode L") + msg = "Image mode must be L" + raise ValueError(msg) outimage = Image.new(image.mode, image.size, None) - count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) + count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage - def match(self, image): + def match(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of coordinates matching the morphological operation on an image. Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise Exception("Image must be binary, meaning it must use mode L") - return _imagingmorph.match(bytes(self.lut), image.im.id) + msg = "Image mode must be L" + raise ValueError(msg) + return _imagingmorph.match(bytes(self.lut), image.getim()) - def get_on_pixels(self, image): + def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of all turned on pixels in a binary image Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" if image.mode != "L": - raise Exception("Image must be binary, meaning it must use mode L") - return _imagingmorph.get_on_pixels(image.im.id) + msg = "Image mode must be L" + raise ValueError(msg) + return _imagingmorph.get_on_pixels(image.getim()) - def load_lut(self, filename): + def load_lut(self, filename: str) -> None: """Load an operator from an mrl file""" with open(filename, "rb") as f: self.lut = bytearray(f.read()) if len(self.lut) != LUT_SIZE: self.lut = None - raise Exception("Wrong size operator file!") + msg = "Wrong size operator file!" + raise Exception(msg) - def save_lut(self, filename): + def save_lut(self, filename: str) -> None: """Save an operator to an mrl file""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) with open(filename, "wb") as f: f.write(self.lut) - def set_lut(self, lut): + def set_lut(self, lut: bytearray | None) -> None: """Set the lut from an external source""" self.lut = lut diff --git a/dependencies/OSX/PIL/ImageOps.py b/dependencies/OSX/PIL/ImageOps.py index 14602a5..bb29cc0 100644 --- a/dependencies/OSX/PIL/ImageOps.py +++ b/dependencies/OSX/PIL/ImageOps.py @@ -16,17 +16,21 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import functools import operator +import re +from collections.abc import Sequence +from typing import Protocol, cast -from . import Image +from . import ExifTags, Image, ImagePalette # # helpers -def _border(border): +def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: if isinstance(border, tuple): if len(border) == 2: left, top = right, bottom = border @@ -37,7 +41,7 @@ def _border(border): return left, top, right, bottom -def _color(color, mode): +def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]: if isinstance(color, str): from . import ImageColor @@ -45,23 +49,31 @@ def _color(color, mode): return color -def _lut(image, lut): +def _lut(image: Image.Image, lut: list[int]) -> Image.Image: if image.mode == "P": # FIXME: apply to lookup table, not image data - raise NotImplementedError("mode P support coming soon") + msg = "mode P support coming soon" + raise NotImplementedError(msg) elif image.mode in ("L", "RGB"): if image.mode == "RGB" and len(lut) == 256: lut = lut + lut + lut return image.point(lut) else: - raise OSError("not supported for this image mode") + msg = f"not supported for mode {image.mode}" + raise OSError(msg) # # actions -def autocontrast(image, cutoff=0, ignore=None, mask=None): +def autocontrast( + image: Image.Image, + cutoff: float | tuple[float, float] = 0, + ignore: int | Sequence[int] | None = None, + mask: Image.Image | None = None, + preserve_tone: bool = False, +) -> Image.Image: """ Maximize (normalize) image contrast. This function calculates a histogram of the input image (or mask region), removes ``cutoff`` percent of the @@ -77,18 +89,25 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None): :param mask: Histogram used in contrast operation is computed using pixels within the mask. If no mask is given the entire image is used for histogram computation. + :param preserve_tone: Preserve image tone in Photoshop-like style autocontrast. + + .. versionadded:: 8.2.0 + :return: An image. """ - histogram = image.histogram(mask) + if preserve_tone: + histogram = image.convert("L").histogram(mask) + else: + histogram = image.histogram(mask) + lut = [] for layer in range(0, len(histogram), 256): h = histogram[layer : layer + 256] if ignore is not None: # get rid of outliers - try: + if isinstance(ignore, int): h[ignore] = 0 - except TypeError: - # assume sequence + else: for ix in ignore: h[ix] = 0 if cutoff: @@ -100,7 +119,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None): for ix in range(256): n = n + h[ix] # remove cutoff% pixels from the low end - cut = n * cutoff[0] // 100 + cut = int(n * cutoff[0] // 100) for lo in range(256): if cut > h[lo]: cut = cut - h[lo] @@ -111,7 +130,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None): if cut <= 0: break # remove cutoff% samples from the high end - cut = n * cutoff[1] // 100 + cut = int(n * cutoff[1] // 100) for hi in range(255, -1, -1): if cut > h[hi]: cut = cut - h[hi] @@ -144,7 +163,15 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None): return _lut(image, lut) -def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127): +def colorize( + image: Image.Image, + black: str | tuple[int, ...], + white: str | tuple[int, ...], + mid: str | int | tuple[int, ...] | None = None, + blackpoint: int = 0, + whitepoint: int = 255, + midpoint: int = 127, +) -> Image.Image: """ Colorize grayscale image. This function calculates a color wedge which maps all black pixels in @@ -176,10 +203,9 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi assert 0 <= blackpoint <= midpoint <= whitepoint <= 255 # Define colors from arguments - black = _color(black, "RGB") - white = _color(white, "RGB") - if mid is not None: - mid = _color(mid, "RGB") + rgb_black = cast(Sequence[int], _color(black, "RGB")) + rgb_white = cast(Sequence[int], _color(white, "RGB")) + rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None # Empty lists for the mapping red = [] @@ -188,56 +214,137 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the low-end values for i in range(0, blackpoint): - red.append(black[0]) - green.append(black[1]) - blue.append(black[2]) + red.append(rgb_black[0]) + green.append(rgb_black[1]) + blue.append(rgb_black[2]) # Create the mapping (2-color) - if mid is None: - + if rgb_mid is None: range_map = range(0, whitepoint - blackpoint) for i in range_map: - red.append(black[0] + i * (white[0] - black[0]) // len(range_map)) - green.append(black[1] + i * (white[1] - black[1]) // len(range_map)) - blue.append(black[2] + i * (white[2] - black[2]) // len(range_map)) + red.append( + rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map) + ) + green.append( + rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map) + ) + blue.append( + rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map) + ) # Create the mapping (3-color) else: - range_map1 = range(0, midpoint - blackpoint) range_map2 = range(0, whitepoint - midpoint) for i in range_map1: - red.append(black[0] + i * (mid[0] - black[0]) // len(range_map1)) - green.append(black[1] + i * (mid[1] - black[1]) // len(range_map1)) - blue.append(black[2] + i * (mid[2] - black[2]) // len(range_map1)) + red.append( + rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1) + ) + green.append( + rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1) + ) + blue.append( + rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1) + ) for i in range_map2: - red.append(mid[0] + i * (white[0] - mid[0]) // len(range_map2)) - green.append(mid[1] + i * (white[1] - mid[1]) // len(range_map2)) - blue.append(mid[2] + i * (white[2] - mid[2]) // len(range_map2)) + red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2)) + green.append( + rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2) + ) + blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) # Create the high-end values for i in range(0, 256 - whitepoint): - red.append(white[0]) - green.append(white[1]) - blue.append(white[2]) + red.append(rgb_white[0]) + green.append(rgb_white[1]) + blue.append(rgb_white[2]) # Return converted image image = image.convert("RGB") return _lut(image, red + green + blue) -def pad(image, size, method=Image.BICUBIC, color=None, centering=(0.5, 0.5)): +def contain( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: + """ + Returns a resized version of the image, set to the maximum width and height + within the requested size, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio > dest_ratio: + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + +def cover( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ - Returns a sized and padded version of the image, expanded to fill the + Returns a resized version of the image, so that the requested size is + covered, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio < dest_ratio: + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + +def pad( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + color: str | int | tuple[int, ...] | None = None, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: + """ + Returns a resized and padded version of the image, expanded to fill the requested aspect ratio and size. - :param image: The image to size and crop. + :param image: The image to resize and crop. :param size: The requested output size in pixels, given as a (width, height) tuple. - :param method: What resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param color: The background color of the padded image. :param centering: Control the position of the original image within the padded version. @@ -249,31 +356,25 @@ def pad(image, size, method=Image.BICUBIC, color=None, centering=(0.5, 0.5)): :return: An image. """ - im_ratio = image.width / image.height - dest_ratio = size[0] / size[1] - - if im_ratio == dest_ratio: - out = image.resize(size, resample=method) + resized = contain(image, size, method) + if resized.size == size: + out = resized else: out = Image.new(image.mode, size, color) - if im_ratio > dest_ratio: - new_height = int(image.height / image.width * size[0]) - if new_height != size[1]: - image = image.resize((size[0], new_height), resample=method) - - y = int((size[1] - new_height) * max(0, min(centering[1], 1))) - out.paste(image, (0, y)) + if resized.palette: + palette = resized.getpalette() + if palette is not None: + out.putpalette(palette) + if resized.width != size[0]: + x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) + out.paste(resized, (x, 0)) else: - new_width = int(image.width / image.height * size[1]) - if new_width != size[0]: - image = image.resize((new_width, size[1]), resample=method) - - x = int((size[0] - new_width) * max(0, min(centering[0], 1))) - out.paste(image, (x, 0)) + y = round((size[1] - resized.height) * max(0, min(centering[1], 1))) + out.paste(resized, (0, y)) return out -def crop(image, border=0): +def crop(image: Image.Image, border: int = 0) -> Image.Image: """ Remove border from image. The same amount of pixels are removed from all four sides. This function works on all image modes. @@ -288,7 +389,9 @@ def crop(image, border=0): return image.crop((left, top, image.size[0] - right, image.size[1] - bottom)) -def scale(image, factor, resample=Image.BICUBIC): +def scale( + image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a rescaled image by a specific factor given in parameter. A factor greater than 1 expands the image, between 0 and 1 contracts the @@ -296,20 +399,42 @@ def scale(image, factor, resample=Image.BICUBIC): :param image: The image to rescale. :param factor: The expansion factor, as a float. - :param resample: What resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :param resample: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :returns: An :py:class:`~PIL.Image.Image` object. """ if factor == 1: return image.copy() elif factor <= 0: - raise ValueError("the factor must be greater than 0") + msg = "the factor must be greater than 0" + raise ValueError(msg) else: size = (round(factor * image.width), round(factor * image.height)) return image.resize(size, resample) -def deform(image, deformer, resample=Image.BILINEAR): +class SupportsGetMesh(Protocol): + """ + An object that supports the ``getmesh`` method, taking an image as an + argument, and returning a list of tuples. Each tuple contains two tuples, + the source box as a tuple of 4 integers, and a tuple of 8 integers for the + final quadrilateral, in order of top left, bottom left, bottom right, top + right. + """ + + def getmesh( + self, image: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: ... + + +def deform( + image: Image.Image, + deformer: SupportsGetMesh, + resample: int = Image.Resampling.BILINEAR, +) -> Image.Image: """ Deform the image. @@ -320,10 +445,12 @@ def deform(image, deformer, resample=Image.BILINEAR): in the PIL.Image.transform function. :return: An image. """ - return image.transform(image.size, Image.MESH, deformer.getmesh(image), resample) + return image.transform( + image.size, Image.Transform.MESH, deformer.getmesh(image), resample + ) -def equalize(image, mask=None): +def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image: """ Equalize the image histogram. This function applies a non-linear mapping to the input image, in order to create a uniform @@ -354,7 +481,11 @@ def equalize(image, mask=None): return _lut(image, lut) -def expand(image, border=0, fill=0): +def expand( + image: Image.Image, + border: int | tuple[int, ...] = 0, + fill: str | int | tuple[int, ...] = 0, +) -> Image.Image: """ Add border to the image @@ -366,23 +497,39 @@ def expand(image, border=0, fill=0): left, top, right, bottom = _border(border) width = left + image.size[0] + right height = top + image.size[1] + bottom - out = Image.new(image.mode, (width, height), _color(fill, image.mode)) + color = _color(fill, image.mode) + if image.palette: + palette = ImagePalette.ImagePalette(palette=image.getpalette()) + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): + color = palette.getcolor(color) + else: + palette = None + out = Image.new(image.mode, (width, height), color) + if palette: + out.putpalette(palette.palette) out.paste(image, (left, top)) return out -def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): +def fit( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + bleed: float = 0.0, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ - Returns a sized and cropped version of the image, cropped to the + Returns a resized and cropped version of the image, cropped to the requested aspect ratio and size. This function was contributed by Kevin Cazabon. - :param image: The image to size and crop. + :param image: The image to resize and crop. :param size: The requested output size in pixels, given as a (width, height) tuple. - :param method: What resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param bleed: Remove a border around the outside of the image from all four edges. The value is a decimal percentage (use 0.01 for one percent). The default value is 0 (no border). @@ -402,15 +549,14 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): # by Kevin Cazabon, Feb 17/2000 # kevin@cazabon.com - # http://www.cazabon.com + # https://www.cazabon.com - # ensure centering is mutable - centering = list(centering) + centering_x, centering_y = centering - if not 0.0 <= centering[0] <= 1.0: - centering[0] = 0.5 - if not 0.0 <= centering[1] <= 1.0: - centering[1] = 0.5 + if not 0.0 <= centering_x <= 1.0: + centering_x = 0.5 + if not 0.0 <= centering_y <= 1.0: + centering_y = 0.5 if not 0.0 <= bleed < 0.5: bleed = 0.0 @@ -447,8 +593,8 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): crop_height = live_size[0] / output_ratio # make the crop - crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering[0] - crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering[1] + crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x + crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height) @@ -456,17 +602,17 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): return image.resize(size, method, box=crop) -def flip(image): +def flip(image: Image.Image) -> Image.Image: """ Flip the image vertically (top to bottom). :param image: The image to flip. :return: An image. """ - return image.transpose(Image.FLIP_TOP_BOTTOM) + return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) -def grayscale(image): +def grayscale(image: Image.Image) -> Image.Image: """ Convert the image to grayscale. @@ -476,30 +622,28 @@ def grayscale(image): return image.convert("L") -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. :param image: The image to invert. :return: An image. """ - lut = [] - for i in range(256): - lut.append(255 - i) - return _lut(image, lut) + lut = list(range(255, -1, -1)) + return image.point(lut) if image.mode == "1" else _lut(image, lut) -def mirror(image): +def mirror(image: Image.Image) -> Image.Image: """ Flip image horizontally (left to right). :param image: The image to mirror. :return: An image. """ - return image.transpose(Image.FLIP_LEFT_RIGHT) + return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) -def posterize(image, bits): +def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. @@ -507,19 +651,17 @@ def posterize(image, bits): :param bits: The number of bits to keep for each channel (1-8). :return: An image. """ - lut = [] mask = ~(2 ** (8 - bits) - 1) - for i in range(256): - lut.append(i & mask) + lut = [i & mask for i in range(256)] return _lut(image, lut) -def solarize(image, threshold=128): +def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: """ Invert all pixel values above a threshold. :param image: The image to solarize. - :param threshold: All pixels above this greyscale level are inverted. + :param threshold: All pixels above this grayscale level are inverted. :return: An image. """ lut = [] @@ -531,28 +673,59 @@ def solarize(image, threshold=128): return _lut(image, lut) -def exif_transpose(image): +def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ - If an image has an EXIF Orientation tag, return a new image that is - transposed accordingly. Otherwise, return a copy of the image. + If an image has an EXIF Orientation tag, other than 1, transpose the image + accordingly, and remove the orientation data. :param image: The image to transpose. - :return: An image. - """ - exif = image.getexif() - orientation = exif.get(0x0112) + :param in_place: Boolean. Keyword-only argument. + If ``True``, the original image is modified in-place, and ``None`` is returned. + If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned + with the transposition applied. If there is no transposition, a copy of the + image will be returned. + """ + image.load() + image_exif = image.getexif() + orientation = image_exif.get(ExifTags.Base.Orientation, 1) method = { - 2: Image.FLIP_LEFT_RIGHT, - 3: Image.ROTATE_180, - 4: Image.FLIP_TOP_BOTTOM, - 5: Image.TRANSPOSE, - 6: Image.ROTATE_270, - 7: Image.TRANSVERSE, - 8: Image.ROTATE_90, + 2: Image.Transpose.FLIP_LEFT_RIGHT, + 3: Image.Transpose.ROTATE_180, + 4: Image.Transpose.FLIP_TOP_BOTTOM, + 5: Image.Transpose.TRANSPOSE, + 6: Image.Transpose.ROTATE_270, + 7: Image.Transpose.TRANSVERSE, + 8: Image.Transpose.ROTATE_90, }.get(orientation) if method is not None: - transposed_image = image.transpose(method) - del exif[0x0112] - transposed_image.info["exif"] = exif.tobytes() - return transposed_image - return image.copy() + if in_place: + image.im = image.im.transpose(method) + image._size = image.im.size + else: + transposed_image = image.transpose(method) + exif_image = image if in_place else transposed_image + + exif = exif_image.getexif() + if ExifTags.Base.Orientation in exif: + del exif[ExifTags.Base.Orientation] + if "exif" in exif_image.info: + exif_image.info["exif"] = exif.tobytes() + elif "Raw profile type exif" in exif_image.info: + exif_image.info["Raw profile type exif"] = exif.tobytes().hex() + for key in ("XML:com.adobe.xmp", "xmp"): + if key in exif_image.info: + for pattern in ( + r'tiff:Orientation="([0-9])"', + r"([0-9])", + ): + value = exif_image.info[key] + exif_image.info[key] = ( + re.sub(pattern, "", value) + if isinstance(value, str) + else re.sub(pattern.encode(), b"", value) + ) + if not in_place: + return transposed_image + elif not in_place: + return image.copy() + return None diff --git a/dependencies/OSX/PIL/ImagePalette.py b/dependencies/OSX/PIL/ImagePalette.py index d060411..183f855 100644 --- a/dependencies/OSX/PIL/ImagePalette.py +++ b/dependencies/OSX/PIL/ImagePalette.py @@ -15,51 +15,77 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import array +from collections.abc import Sequence +from typing import IO, TYPE_CHECKING from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ Color palette for palette mapped images - :param mode: The mode to use for the Palette. See: + :param mode: The mode to use for the palette. See: :ref:`concept-modes`. Defaults to "RGB" :param palette: An optional palette. If given, it must be a bytearray, - an array or a list of ints between 0-255 and of length ``size`` - times the number of colors in ``mode``. The list must be aligned - by channel (All R values must be contiguous in the list before G - and B values.) Defaults to 0 through 255 per channel. - :param size: An optional palette size. If given, it cannot be equal to - or greater than 256. Defaults to 0. + an array or a list of ints between 0-255. The list must consist of + all channels for one color followed by the next color (e.g. RGBRGBRGB). + Defaults to an empty palette. """ - def __init__(self, mode="RGB", palette=None, size=0): + def __init__( + self, + mode: str = "RGB", + palette: Sequence[int] | bytes | bytearray | None = None, + ) -> None: self.mode = mode - self.rawmode = None # if set, palette contains raw data - self.palette = palette or bytearray(range(256)) * len(self.mode) - self.colors = {} - self.dirty = None - if (size == 0 and len(self.mode) * 256 != len(self.palette)) or ( - size != 0 and size != len(self.palette) - ): - raise ValueError("wrong palette size") - - def copy(self): + self.rawmode: str | None = None # if set, palette contains raw data + self.palette = palette or bytearray() + self.dirty: int | None = None + + @property + def palette(self) -> Sequence[int] | bytes | bytearray: + return self._palette + + @palette.setter + def palette(self, palette: Sequence[int] | bytes | bytearray) -> None: + self._colors: dict[tuple[int, ...], int] | None = None + self._palette = palette + + @property + def colors(self) -> dict[tuple[int, ...], int]: + if self._colors is None: + mode_len = len(self.mode) + self._colors = {} + for i in range(0, len(self.palette), mode_len): + color = tuple(self.palette[i : i + mode_len]) + if color in self._colors: + continue + self._colors[color] = i // mode_len + return self._colors + + @colors.setter + def colors(self, colors: dict[tuple[int, ...], int]) -> None: + self._colors = colors + + def copy(self) -> ImagePalette: new = ImagePalette() new.mode = self.mode new.rawmode = self.rawmode if self.palette is not None: new.palette = self.palette[:] - new.colors = self.colors.copy() new.dirty = self.dirty return new - def getdata(self): + def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]: """ Get palette contents in format suitable for the low-level ``im.putpalette`` primitive. @@ -68,58 +94,101 @@ def getdata(self): """ if self.rawmode: return self.rawmode, self.palette - return self.mode + ";L", self.tobytes() + return self.mode, self.tobytes() - def tobytes(self): + def tobytes(self) -> bytes: """Convert palette to bytes. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(self.palette, bytes): return self.palette arr = array.array("B", self.palette) - if hasattr(arr, "tobytes"): - return arr.tobytes() - return arr.tostring() + return arr.tobytes() # Declare tostring as an alias for tobytes tostring = tobytes - def getcolor(self, color): + def _new_color_index( + self, image: Image.Image | None = None, e: Exception | None = None + ) -> int: + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // 3 + special_colors: tuple[int | tuple[int, ...] | None, ...] = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 + if index >= 256: + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + msg = "cannot allocate more than 256 colors" + raise ValueError(msg) from e + return index + + def getcolor( + self, + color: tuple[int, ...], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(color, tuple): + if self.mode == "RGB": + if len(color) == 4: + if color[3] != 255: + msg = "cannot add non-opaque RGBA color to RGB palette" + raise ValueError(msg) + color = color[:3] + elif self.mode == "RGBA": + if len(color) == 3: + color += (255,) try: return self.colors[color] except KeyError as e: # allocate new color slot - if isinstance(self.palette, bytes): - self.palette = bytearray(self.palette) - index = len(self.colors) - if index >= 256: - raise ValueError("cannot allocate more than 256 colors") from e + index = self._new_color_index(image, e) + assert isinstance(self._palette, bytearray) self.colors[color] = index - self.palette[index] = color[0] - self.palette[index + 256] = color[1] - self.palette[index + 512] = color[2] + if index * 3 < len(self.palette): + self._palette = ( + self._palette[: index * 3] + + bytes(color) + + self._palette[index * 3 + 3 :] + ) + else: + self._palette += bytes(color) self.dirty = 1 return index else: - raise ValueError(f"unknown color specifier: {repr(color)}") + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] + raise ValueError(msg) - def save(self, fp): + def save(self, fp: str | IO[str]) -> None: """Save palette to text file. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(fp, str): fp = open(fp, "w") fp.write("# Palette\n") @@ -139,7 +208,7 @@ def save(self, fp): # Internal -def raw(rawmode, data): +def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data @@ -151,71 +220,66 @@ def raw(rawmode, data): # Factories -def make_linear_lut(black, white): - lut = [] +def make_linear_lut(black: int, white: float) -> list[int]: if black == 0: - for i in range(256): - lut.append(white * i // 255) - else: - raise NotImplementedError # FIXME - return lut + return [int(white * i // 255) for i in range(256)] + + msg = "unavailable when black is non-zero" + raise NotImplementedError(msg) # FIXME -def make_gamma_lut(exp): - lut = [] - for i in range(256): - lut.append(int(((i / 255.0) ** exp) * 255.0 + 0.5)) - return lut +def make_gamma_lut(exp: float) -> list[int]: + return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] -def negative(mode="RGB"): - palette = list(range(256)) +def negative(mode: str = "RGB") -> ImagePalette: + palette = list(range(256 * len(mode))) palette.reverse() - return ImagePalette(mode, palette * len(mode)) + return ImagePalette(mode, [i // len(mode) for i in palette]) -def random(mode="RGB"): +def random(mode: str = "RGB") -> ImagePalette: from random import randint - palette = [] - for i in range(256 * len(mode)): - palette.append(randint(0, 255)) + palette = [randint(0, 255) for _ in range(256 * len(mode))] return ImagePalette(mode, palette) -def sepia(white="#fff0c0"): - r, g, b = ImageColor.getrgb(white) - r = make_linear_lut(0, r) - g = make_linear_lut(0, g) - b = make_linear_lut(0, b) - return ImagePalette("RGB", r + g + b) +def sepia(white: str = "#fff0c0") -> ImagePalette: + bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] + return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) -def wedge(mode="RGB"): - return ImagePalette(mode, list(range(256)) * len(mode)) +def wedge(mode: str = "RGB") -> ImagePalette: + palette = list(range(256 * len(mode))) + return ImagePalette(mode, [i // len(mode) for i in palette]) -def load(filename): - +def load(filename: str) -> tuple[bytes, str]: # FIXME: supports GIMP gradients only with open(filename, "rb") as fp: - - for paletteHandler in [ + paletteHandlers: list[ + type[ + GimpPaletteFile.GimpPaletteFile + | GimpGradientFile.GimpGradientFile + | PaletteFile.PaletteFile + ] + ] = [ GimpPaletteFile.GimpPaletteFile, GimpGradientFile.GimpGradientFile, PaletteFile.PaletteFile, - ]: + ] + for paletteHandler in paletteHandlers: try: fp.seek(0) lut = paletteHandler(fp).getpalette() if lut: break except (SyntaxError, ValueError): - # import traceback - # traceback.print_exc() pass else: - raise OSError("cannot load palette") + msg = "cannot load palette" + raise OSError(msg) return lut # data, rawmode diff --git a/dependencies/OSX/PIL/ImagePath.py b/dependencies/OSX/PIL/ImagePath.py index 3d3538c..77e8a60 100644 --- a/dependencies/OSX/PIL/ImagePath.py +++ b/dependencies/OSX/PIL/ImagePath.py @@ -13,6 +13,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image diff --git a/dependencies/OSX/PIL/ImageQt.py b/dependencies/OSX/PIL/ImageQt.py index 91be534..2cc40f8 100644 --- a/dependencies/OSX/PIL/ImageQt.py +++ b/dependencies/OSX/PIL/ImageQt.py @@ -15,48 +15,75 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys from io import BytesIO +from typing import TYPE_CHECKING, Any, Callable, Union from . import Image -from ._util import isPath +from ._util import is_path -qt_versions = [["5", "PyQt5"], ["side2", "PySide2"]] +if TYPE_CHECKING: + import PyQt6 + import PySide6 + + from . import ImageFile + + QBuffer: type + QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray] + QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + +qt_version: str | None +qt_versions = [ + ["6", "PyQt6"], + ["side6", "PySide6"], +] # If a version has already been imported, attempt it first -qt_versions.sort(key=lambda qt_version: qt_version[1] in sys.modules, reverse=True) -for qt_version, qt_module in qt_versions: +qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) +for version, qt_module in qt_versions: try: - if qt_module == "PyQt5": - from PyQt5.QtCore import QBuffer, QIODevice - from PyQt5.QtGui import QImage, QPixmap, qRgba - elif qt_module == "PySide2": - from PySide2.QtCore import QBuffer, QIODevice - from PySide2.QtGui import QImage, QPixmap, qRgba + qRgba: Callable[[int, int, int, int], int] + if qt_module == "PyQt6": + from PyQt6.QtCore import QBuffer, QIODevice + from PyQt6.QtGui import QImage, QPixmap, qRgba + elif qt_module == "PySide6": + from PySide6.QtCore import QBuffer, QIODevice + from PySide6.QtGui import QImage, QPixmap, qRgba except (ImportError, RuntimeError): continue qt_is_installed = True + qt_version = version break else: qt_is_installed = False qt_version = None -def rgb(r, g, b, a=255): +def rgb(r: int, g: int, b: int, a: int = 255) -> int: """(Internal) Turns an RGB color into a Qt compatible color integer.""" # use qRgb to pack the colors, and then turn the resulting long # into a negative integer with the same bitpattern. return qRgba(r, g, b, a) & 0xFFFFFFFF -def fromqimage(im): +def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile: """ - :param im: A PIL Image object, or a file name - (given either as Python string or a PyQt string object) + :param im: QImage or PIL ImageQt object """ buffer = QBuffer() - buffer.open(QIODevice.ReadWrite) + qt_openmode: object + if qt_version == "6": + try: + qt_openmode = getattr(QIODevice, "OpenModeFlag") + except AttributeError: + qt_openmode = getattr(QIODevice, "OpenMode") + else: + qt_openmode = QIODevice + buffer.open(getattr(qt_openmode, "ReadWrite")) # preserve alpha channel with png # otherwise ppm is more friendly with Image.open if im.hasAlphaChannel(): @@ -72,26 +99,16 @@ def fromqimage(im): return Image.open(b) -def fromqpixmap(im): +def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile: return fromqimage(im) - # buffer = QBuffer() - # buffer.open(QIODevice.ReadWrite) - # # im.save(buffer) - # # What if png doesn't support some image features like animation? - # im.save(buffer, 'ppm') - # bytes_io = BytesIO() - # bytes_io.write(buffer.data()) - # buffer.close() - # bytes_io.seek(0) - # return Image.open(bytes_io) - - -def align8to32(bytes, width, mode): + + +def align8to32(bytes: bytes, width: int, mode: str) -> bytes: """ converts each scanline of data from 8 bit to 32 bit aligned """ - bits_per_pixel = {"1": 1, "L": 8, "P": 8}[mode] + bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode] # calculate bytes per line and the extra padding if needed bits_per_line = bits_per_pixel * width @@ -104,57 +121,69 @@ def align8to32(bytes, width, mode): if not extra_padding: return bytes - new_data = [] - for i in range(len(bytes) // bytes_per_line): - new_data.append( - bytes[i * bytes_per_line : (i + 1) * bytes_per_line] - + b"\x00" * extra_padding - ) + new_data = [ + bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding + for i in range(len(bytes) // bytes_per_line) + ] return b"".join(new_data) -def _toqclass_helper(im): +def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: data = None colortable = None + exclusive_fp = False # handle filename, if given instead of image name if hasattr(im, "toUtf8"): # FIXME - is this really the best way to do this? im = str(im.toUtf8(), "utf-8") - if isPath(im): + if is_path(im): im = Image.open(im) + exclusive_fp = True + assert isinstance(im, Image.Image) + qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage if im.mode == "1": - format = QImage.Format_Mono + format = getattr(qt_format, "Format_Mono") elif im.mode == "L": - format = QImage.Format_Indexed8 - colortable = [] - for i in range(256): - colortable.append(rgb(i, i, i)) + format = getattr(qt_format, "Format_Indexed8") + colortable = [rgb(i, i, i) for i in range(256)] elif im.mode == "P": - format = QImage.Format_Indexed8 - colortable = [] + format = getattr(qt_format, "Format_Indexed8") palette = im.getpalette() - for i in range(0, len(palette), 3): - colortable.append(rgb(*palette[i : i + 3])) + assert palette is not None + colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] elif im.mode == "RGB": - data = im.tobytes("raw", "BGRX") - format = QImage.Format_RGB32 + # Populate the 4th channel with 255 + im = im.convert("RGBA") + + data = im.tobytes("raw", "BGRA") + format = getattr(qt_format, "Format_RGB32") elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") - format = QImage.Format_ARGB32 + format = getattr(qt_format, "Format_ARGB32") + elif im.mode == "I;16": + im = im.point(lambda i: i * 256) + + format = getattr(qt_format, "Format_Grayscale16") else: - raise ValueError(f"unsupported image mode {repr(im.mode)}") + if exclusive_fp: + im.close() + msg = f"unsupported image mode {repr(im.mode)}" + raise ValueError(msg) - __data = data or align8to32(im.tobytes(), im.size[0], im.mode) - return {"data": __data, "im": im, "format": format, "colortable": colortable} + size = im.size + __data = data or align8to32(im.tobytes(), size[0], im.mode) + if exclusive_fp: + im.close() + return {"data": __data, "size": size, "format": format, "colortable": colortable} if qt_is_installed: - class ImageQt(QImage): - def __init__(self, im): + class ImageQt(QImage): # type: ignore[misc] + def __init__(self, im: Image.Image | str | QByteArray) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage class. @@ -170,26 +199,21 @@ def __init__(self, im): self.__data = im_data["data"] super().__init__( self.__data, - im_data["im"].size[0], - im_data["im"].size[1], + im_data["size"][0], + im_data["size"][1], im_data["format"], ) if im_data["colortable"]: self.setColorTable(im_data["colortable"]) -def toqimage(im): +def toqimage(im: Image.Image | str | QByteArray) -> ImageQt: return ImageQt(im) -def toqpixmap(im): - # # This doesn't work. For now using a dumb approach. - # im_data = _toqclass_helper(im) - # result = QPixmap(im_data['im'].size[0], im_data['im'].size[1]) - # result.loadFromData(im_data['data']) - # Fix some strange bug that causes - if im.mode == "RGB": - im = im.convert("RGBA") - +def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap: qimage = toqimage(im) - return QPixmap.fromImage(qimage) + pixmap = getattr(QPixmap, "fromImage")(qimage) + if qt_version == "6": + pixmap.detach() + return pixmap diff --git a/dependencies/OSX/PIL/ImageSequence.py b/dependencies/OSX/PIL/ImageSequence.py index 9df910a..a6fc340 100644 --- a/dependencies/OSX/PIL/ImageSequence.py +++ b/dependencies/OSX/PIL/ImageSequence.py @@ -14,6 +14,11 @@ # ## +from __future__ import annotations + +from typing import Callable + +from . import Image class Iterator: @@ -28,32 +33,38 @@ class Iterator: :param im: An image object. """ - def __init__(self, im): + def __init__(self, im: Image.Image) -> None: if not hasattr(im, "seek"): - raise AttributeError("im must have seek method") + msg = "im must have seek method" + raise AttributeError(msg) self.im = im self.position = getattr(self.im, "_min_frame", 0) - def __getitem__(self, ix): + def __getitem__(self, ix: int) -> Image.Image: try: self.im.seek(ix) return self.im except EOFError as e: - raise IndexError from e # end of sequence + msg = "end of sequence" + raise IndexError(msg) from e - def __iter__(self): + def __iter__(self) -> Iterator: return self - def __next__(self): + def __next__(self) -> Image.Image: try: self.im.seek(self.position) self.position += 1 return self.im except EOFError as e: - raise StopIteration from e + msg = "end of sequence" + raise StopIteration(msg) from e -def all_frames(im, func=None): +def all_frames( + im: Image.Image | list[Image.Image], + func: Callable[[Image.Image], Image.Image] | None = None, +) -> list[Image.Image]: """ Applies a given function to all frames in an image or a list of images. The frames are returned as a list of separate images. diff --git a/dependencies/OSX/PIL/ImageShow.py b/dependencies/OSX/PIL/ImageShow.py index 1ada825..d62893d 100644 --- a/dependencies/OSX/PIL/ImageShow.py +++ b/dependencies/OSX/PIL/ImageShow.py @@ -11,39 +11,44 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import os import shutil import subprocess import sys -import tempfile from shlex import quote +from typing import Any -from PIL import Image +from . import Image _viewers = [] -def register(viewer, order=1): +def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None: """ - The :py:func:`register` function is used to register additional viewers. + The :py:func:`register` function is used to register additional viewers:: + + from PIL import ImageShow + ImageShow.register(MyViewer()) # MyViewer will be used as a last resort + ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised + ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised :param viewer: The viewer to be registered. :param order: Zero or a negative integer to prepend this viewer to the list, a positive integer to append it. """ - try: - if issubclass(viewer, Viewer): - viewer = viewer() - except TypeError: - pass # raised if viewer wasn't a class + if isinstance(viewer, type) and issubclass(viewer, Viewer): + viewer = viewer() if order > 0: _viewers.append(viewer) else: _viewers.insert(0, viewer) -def show(image, title=None, **options): +def show(image: Image.Image, title: str | None = None, **options: Any) -> bool: r""" Display a given image. @@ -54,8 +59,8 @@ def show(image, title=None, **options): """ for viewer in _viewers: if viewer.show(image, title=title, **options): - return 1 - return 0 + return True + return False class Viewer: @@ -63,13 +68,12 @@ class Viewer: # main api - def show(self, image, **options): + def show(self, image: Image.Image, **options: Any) -> int: """ The main function for displaying an image. Converts the given image to the target format and displays it. """ - # save temporary image to disk if not ( image.mode in ("1", "RGBA") or (self.format == "PNG" and image.mode in ("I;16", "LA")) @@ -82,33 +86,38 @@ def show(self, image, **options): # hook methods - format = None + format: str | None = None """The format to convert the image into.""" - options = {} + options: dict[str, Any] = {} """Additional options used to convert the image.""" - def get_format(self, image): + def get_format(self, image: Image.Image) -> str | None: """Return format name, or ``None`` to save as PGM/PPM.""" return self.format - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: """ Returns the command used to display the file. Not implemented in the base class. """ - raise NotImplementedError + msg = "unavailable in base viewer" + raise NotImplementedError(msg) - def save_image(self, image): + def save_image(self, image: Image.Image) -> str: """Save to temporary file and return filename.""" return image._dump(format=self.get_format(image), **self.options) - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> int: """Display the given image.""" return self.show_file(self.save_image(image), **options) - def show_file(self, file, **options): - """Display the given file.""" - os.system(self.get_command(file, **options)) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + os.system(self.get_command(path, **options)) # nosec return 1 @@ -119,45 +128,63 @@ class WindowsViewer(Viewer): """The default viewer on Windows is the default system application for PNG files.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' - "&& ping -n 2 127.0.0.1 >NUL " + "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' ) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen( + self.get_command(path, **options), + shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), + ) # nosec + return 1 + if sys.platform == "win32": register(WindowsViewer) class MacViewer(Viewer): - """The default viewer on MacOS using ``Preview.app``.""" + """The default viewer on macOS using ``Preview.app``.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: # on darwin open returns immediately resulting in the temp # file removal while app is opening command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command - def show_file(self, file, **options): - """Display given file""" - fd, path = tempfile.mkstemp() - with os.fdopen(fd, "w") as f: - f.write(file) - with open(path) as f: + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.call(["open", "-a", "Preview.app", path]) + executable = sys.executable or shutil.which("python3") + if executable: subprocess.Popen( - ["im=$(cat); open -a Preview.app $im; sleep 20; rm -f $im"], - shell=True, - stdin=f, + [ + executable, + "-c", + "import os, sys, time; time.sleep(20); os.remove(sys.argv[1])", + path, + ] ) - os.remove(path) return 1 @@ -167,41 +194,101 @@ def show_file(self, file, **options): class UnixViewer(Viewer): format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + @abc.abstractmethod + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + pass + + def get_command(self, file: str, **options: Any) -> str: command = self.get_command_ex(file, **options)[0] - return f"({command} {quote(file)}; rm -f {quote(file)})&" - - def show_file(self, file, **options): - """Display given file""" - fd, path = tempfile.mkstemp() - with os.fdopen(fd, "w") as f: - f.write(file) - with open(path) as f: - command = self.get_command_ex(file, **options)[0] - subprocess.Popen( - ["im=$(cat);" + command + " $im; rm -f $im"], shell=True, stdin=f - ) - os.remove(path) + return f"{command} {quote(file)}" + + +class XDGViewer(UnixViewer): + """ + The freedesktop.org ``xdg-open`` command. + """ + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + command = executable = "xdg-open" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["xdg-open", path]) return 1 class DisplayViewer(UnixViewer): - """The ImageMagick ``display`` command.""" + """ + The ImageMagick ``display`` command. + This viewer supports the ``title`` parameter. + """ - def get_command_ex(self, file, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: command = executable = "display" + if title: + command += f" -title {quote(title)}" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["display"] + title = options.get("title") + if title: + args += ["-title", title] + args.append(path) + + subprocess.Popen(args) + return 1 + + +class GmDisplayViewer(UnixViewer): + """The GraphicsMagick ``gm display`` command.""" + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + executable = "gm" + command = "gm display" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["gm", "display", path]) + return 1 + class EogViewer(UnixViewer): """The GNOME Image Viewer ``eog`` command.""" - def get_command_ex(self, file, **options): - command = executable = "eog" + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + executable = "eog" + command = "eog -n" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["eog", "-n", path]) + return 1 + class XVViewer(UnixViewer): """ @@ -209,7 +296,9 @@ class XVViewer(UnixViewer): This viewer supports the ``title`` parameter. """ - def get_command_ex(self, file, title=None, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: # note: xv is pretty outdated. most modern systems have # imagemagick's display command instead. command = executable = "xv" @@ -217,19 +306,54 @@ def get_command_ex(self, file, title=None, **options): command += f" -name {quote(title)}" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["xv"] + title = options.get("title") + if title: + args += ["-name", title] + args.append(path) + + subprocess.Popen(args) + return 1 + if sys.platform not in ("win32", "darwin"): # unixoids + if shutil.which("xdg-open"): + register(XDGViewer) if shutil.which("display"): register(DisplayViewer) + if shutil.which("gm"): + register(GmDisplayViewer) if shutil.which("eog"): register(EogViewer) if shutil.which("xv"): register(XVViewer) -if __name__ == "__main__": +class IPythonViewer(Viewer): + """The viewer for IPython frontends.""" + + def show_image(self, image: Image.Image, **options: Any) -> int: + ipython_display(image) + return 1 + + +try: + from IPython.display import display as ipython_display +except ImportError: + pass +else: + register(IPythonViewer) + + +if __name__ == "__main__": if len(sys.argv) < 2: - print("Syntax: python ImageShow.py imagefile [title]") + print("Syntax: python3 ImageShow.py imagefile [title]") sys.exit() with Image.open(sys.argv[1]) as im: diff --git a/dependencies/OSX/PIL/ImageStat.py b/dependencies/OSX/PIL/ImageStat.py index 50bafc9..8bc5045 100644 --- a/dependencies/OSX/PIL/ImageStat.py +++ b/dependencies/OSX/PIL/ImageStat.py @@ -20,91 +20,111 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations -import functools import math -import operator +from functools import cached_property + +from . import Image class Stat: - def __init__(self, image_or_list, mask=None): - try: - if mask: - self.h = image_or_list.histogram(mask) - else: - self.h = image_or_list.histogram() - except AttributeError: - self.h = image_or_list # assume it to be a histogram list - if not isinstance(self.h, list): - raise TypeError("first argument must be image or list") + def __init__( + self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None + ) -> None: + """ + Calculate statistics for the given image. If a mask is included, + only the regions covered by that mask are included in the + statistics. You can also pass in a previously calculated histogram. + + :param image: A PIL image, or a precalculated histogram. + + .. note:: + + For a PIL image, calculations rely on the + :py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are + grouped into 256 bins, even if the image has more than 8 bits per + channel. So ``I`` and ``F`` mode images have a maximum ``mean``, + ``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum + of more than 255. + + :param mask: An optional mask. + """ + if isinstance(image_or_list, Image.Image): + self.h = image_or_list.histogram(mask) + elif isinstance(image_or_list, list): + self.h = image_or_list + else: + msg = "first argument must be image or list" # type: ignore[unreachable] + raise TypeError(msg) self.bands = list(range(len(self.h) // 256)) - def __getattr__(self, id): - """Calculate missing attribute""" - if id[:4] == "_get": - raise AttributeError(id) - # calculate missing attribute - v = getattr(self, "_get" + id)() - setattr(self, id, v) - return v - - def _getextrema(self): - """Get min/max values for each band in the image""" - - def minmax(histogram): - n = 255 - x = 0 + @cached_property + def extrema(self) -> list[tuple[int, int]]: + """ + Min/max values for each band in the image. + + .. note:: + This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and + simply returns the low and high bins used. This is correct for + images with 8 bits per channel, but fails for other modes such as + ``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to + return per-band extrema for the image. This is more correct and + efficient because, for non-8-bit modes, the histogram method uses + :py:meth:`~PIL.Image.Image.getextrema` to determine the bins used. + """ + + def minmax(histogram: list[int]) -> tuple[int, int]: + res_min, res_max = 255, 0 for i in range(256): if histogram[i]: - n = min(n, i) - x = max(x, i) - return n, x # returns (255, 0) if there's no data in the histogram - - v = [] - for i in range(0, len(self.h), 256): - v.append(minmax(self.h[i:])) - return v + res_min = i + break + for i in range(255, -1, -1): + if histogram[i]: + res_max = i + break + return res_min, res_max - def _getcount(self): - """Get total number of pixels in each layer""" + return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)] - v = [] - for i in range(0, len(self.h), 256): - v.append(functools.reduce(operator.add, self.h[i : i + 256])) - return v + @cached_property + def count(self) -> list[int]: + """Total number of pixels for each band in the image.""" + return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)] - def _getsum(self): - """Get sum of all pixels in each layer""" + @cached_property + def sum(self) -> list[float]: + """Sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): - layerSum = 0.0 + layer_sum = 0.0 for j in range(256): - layerSum += j * self.h[i + j] - v.append(layerSum) + layer_sum += j * self.h[i + j] + v.append(layer_sum) return v - def _getsum2(self): - """Get squared sum of all pixels in each layer""" + @cached_property + def sum2(self) -> list[float]: + """Squared sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): sum2 = 0.0 for j in range(256): - sum2 += (j ** 2) * float(self.h[i + j]) + sum2 += (j**2) * float(self.h[i + j]) v.append(sum2) return v - def _getmean(self): - """Get average pixel level for each layer""" - - v = [] - for i in self.bands: - v.append(self.sum[i] / self.count[i]) - return v + @cached_property + def mean(self) -> list[float]: + """Average (arithmetic mean) pixel level for each band in the image.""" + return [self.sum[i] / self.count[i] for i in self.bands] - def _getmedian(self): - """Get median pixel level for each layer""" + @cached_property + def median(self) -> list[int]: + """Median pixel level for each band in the image.""" v = [] for i in self.bands: @@ -118,30 +138,23 @@ def _getmedian(self): v.append(j) return v - def _getrms(self): - """Get RMS for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.sum2[i] / self.count[i])) - return v - - def _getvar(self): - """Get variance for each layer""" - - v = [] - for i in self.bands: - n = self.count[i] - v.append((self.sum2[i] - (self.sum[i] ** 2.0) / n) / n) - return v - - def _getstddev(self): - """Get standard deviation for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.var[i])) - return v + @cached_property + def rms(self) -> list[float]: + """RMS (root-mean-square) for each band in the image.""" + return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands] + + @cached_property + def var(self) -> list[float]: + """Variance for each band in the image.""" + return [ + (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] + for i in self.bands + ] + + @cached_property + def stddev(self) -> list[float]: + """Standard deviation for each band in the image.""" + return [math.sqrt(self.var[i]) for i in self.bands] Global = Stat # compatibility diff --git a/dependencies/OSX/PIL/ImageTk.py b/dependencies/OSX/PIL/ImageTk.py index 62db7a7..bf29fdb 100644 --- a/dependencies/OSX/PIL/ImageTk.py +++ b/dependencies/OSX/PIL/ImageTk.py @@ -24,38 +24,45 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import tkinter from io import BytesIO +from typing import TYPE_CHECKING, Any, cast -from . import Image +from . import Image, ImageFile + +if TYPE_CHECKING: + from ._typing import CapsuleType # -------------------------------------------------------------------- # Check for Tkinter interface hooks -_pilbitmap_ok = None - - -def _pilbitmap_check(): - global _pilbitmap_ok - if _pilbitmap_ok is None: - try: - im = Image.new("1", (1, 1)) - tkinter.BitmapImage(data=f"PIL:{im.im.id}") - _pilbitmap_ok = 1 - except tkinter.TclError: - _pilbitmap_ok = 0 - return _pilbitmap_ok - -def _get_image_from_kw(kw): +def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: source = None if "file" in kw: source = kw.pop("file") elif "data" in kw: source = BytesIO(kw.pop("data")) - if source: - return Image.open(source) + if not source: + return None + return Image.open(source) + + +def _pyimagingtkcall( + command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType +) -> None: + tk = photo.tk + try: + tk.call(command, photo, repr(ptr)) + except tkinter.TclError: + # activate Tkinter hook + # may raise an error if it cannot attach to Tkinter + from . import _imagingtk + + _imagingtk.tkinit(tk.interpaddr()) + tk.call(command, photo, repr(ptr)) # -------------------------------------------------------------------- @@ -82,27 +89,36 @@ class PhotoImage: image file). """ - def __init__(self, image=None, size=None, **kw): - + def __init__( + self, + image: Image.Image | str | None = None, + size: tuple[int, int] | None = None, + **kw: Any, + ) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) - if hasattr(image, "mode") and hasattr(image, "size"): + if image is None: + msg = "Image is required" + raise ValueError(msg) + elif isinstance(image, str): + mode = image + image = None + + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: # got an image instead of a mode mode = image.mode if mode == "P": # palette mapped data + image.apply_transparency() image.load() - try: - mode = image.palette.mode - except AttributeError: - mode = "RGB" # default + mode = image.palette.mode if image.palette else "RGB" size = image.size kw["width"], kw["height"] = size - else: - mode = image - image = None if mode not in ["1", "L", "RGB", "RGBA"]: mode = Image.getmodebase(mode) @@ -114,15 +130,18 @@ def __init__(self, image=None, size=None, **kw): if image: self.paste(image) - def __del__(self): - name = self.__photo.name + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) except Exception: pass # ignore internal errors - def __str__(self): + def __str__(self) -> str: """ Get the Tkinter photo image identifier. This method is automatically called by Tkinter whenever a PhotoImage object is passed to a Tkinter @@ -132,7 +151,7 @@ def __str__(self): """ return str(self.__photo) - def width(self): + def width(self) -> int: """ Get the width of the image. @@ -140,7 +159,7 @@ def width(self): """ return self.__size[0] - def height(self): + def height(self) -> int: """ Get the height of the image. @@ -148,7 +167,7 @@ def height(self): """ return self.__size[1] - def paste(self, im, box=None): + def paste(self, im: Image.Image) -> None: """ Paste a PIL image into the photo image. Note that this can be very slow if the photo image is displayed. @@ -156,47 +175,16 @@ def paste(self, im, box=None): :param im: A PIL image. The size must match the target region. If the mode does not match, the image is converted to the mode of the bitmap image. - :param box: A 4-tuple defining the left, upper, right, and lower pixel - coordinate. See :ref:`coordinate-system`. If None is given - instead of a tuple, all of the image is assumed. """ - # convert to blittable - im.load() + ptr = im.getim() image = im.im - if image.isblock() and im.mode == self.__mode: - block = image - else: - block = image.new_block(self.__mode, im.size) + if not image.isblock() or im.mode != self.__mode: + block = Image.core.new_block(self.__mode, im.size) image.convert2(block, image) # convert directly between buffers + ptr = block.ptr - tk = self.__photo.tk - - try: - tk.call("PyImagingPhoto", self.__photo, block.id) - except tkinter.TclError: - # activate Tkinter hook - try: - from . import _imagingtk - - try: - if hasattr(tk, "interp"): - # Required for PyPy, which always has CFFI installed - from cffi import FFI - - ffi = FFI() - - # PyPy is using an FFI CDATA element - # (Pdb) self.tk.interp - # - _imagingtk.tkinit(int(ffi.cast("uintptr_t", tk.interp)), 1) - else: - _imagingtk.tkinit(tk.interpaddr(), 1) - except AttributeError: - _imagingtk.tkinit(id(tk), 0) - tk.call("PyImagingPhoto", self.__photo, block.id) - except (ImportError, AttributeError, tkinter.TclError): - raise # configuration problem; cannot attach to Tkinter + _pyimagingtkcall("PyImagingPhoto", self.__photo, ptr) # -------------------------------------------------------------------- @@ -217,34 +205,31 @@ class BitmapImage: :param image: A PIL image. """ - def __init__(self, image=None, **kw): - + def __init__(self, image: Image.Image | None = None, **kw: Any) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) + if image is None: + msg = "Image is required" + raise ValueError(msg) self.__mode = image.mode self.__size = image.size - if _pilbitmap_check(): - # fast way (requires the pilbitmap booster patch) - image.load() - kw["data"] = f"PIL:{image.im.id}" - self.__im = image # must keep a reference - else: - # slow but safe way - kw["data"] = image.tobitmap() - self.__photo = tkinter.BitmapImage(**kw) + self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw) - def __del__(self): - name = self.__photo.name + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) except Exception: pass # ignore internal errors - def width(self): + def width(self) -> int: """ Get the width of the image. @@ -252,7 +237,7 @@ def width(self): """ return self.__size[0] - def height(self): + def height(self) -> int: """ Get the height of the image. @@ -260,7 +245,7 @@ def height(self): """ return self.__size[1] - def __str__(self): + def __str__(self) -> str: """ Get the Tkinter bitmap image identifier. This method is automatically called by Tkinter whenever a BitmapImage object is passed to a Tkinter @@ -271,29 +256,34 @@ def __str__(self): return str(self.__photo) -def getimage(photo): +def getimage(photo: PhotoImage) -> Image.Image: """Copies the contents of a PhotoImage to a PIL image memory.""" im = Image.new("RGBA", (photo.width(), photo.height())) - block = im.im - photo.tk.call("PyImagingPhotoGet", photo, block.id) + _pyimagingtkcall("PyImagingPhotoGet", photo, im.getim()) return im -def _show(image, title): +def _show(image: Image.Image, title: str | None) -> None: """Helper for the Image.show method.""" class UI(tkinter.Label): - def __init__(self, master, im): + def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None: + self.image: BitmapImage | PhotoImage if im.mode == "1": self.image = BitmapImage(im, foreground="white", master=master) else: self.image = PhotoImage(im, master=master) - super().__init__(master, image=self.image, bg="black", bd=0) + if TYPE_CHECKING: + image = cast(tkinter._Image, self.image) + else: + image = self.image + super().__init__(master, image=image, bg="black", bd=0) - if not tkinter._default_root: - raise OSError("tkinter not initialized") + if not getattr(tkinter, "_default_root"): + msg = "tkinter not initialized" + raise OSError(msg) top = tkinter.Toplevel() if title: top.title(title) diff --git a/dependencies/OSX/PIL/ImageTransform.py b/dependencies/OSX/PIL/ImageTransform.py index 77791ab..a3d8f44 100644 --- a/dependencies/OSX/PIL/ImageTransform.py +++ b/dependencies/OSX/PIL/ImageTransform.py @@ -12,18 +12,32 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any from . import Image class Transform(Image.ImageTransformHandler): - def __init__(self, data): + """Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`.""" + + method: Image.Transform + + def __init__(self, data: Sequence[Any]) -> None: self.data = data - def getdata(self): + def getdata(self) -> tuple[Image.Transform, Sequence[int]]: return self.method, self.data - def transform(self, size, image, **options): + def transform( + self, + size: tuple[int, int], + image: Image.Image, + **options: Any, + ) -> Image.Image: + """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden method, data = self.getdata() return image.transform(size, method, data, **options) @@ -41,13 +55,33 @@ class AffineTransform(Transform): This function can be used to scale, translate, rotate, and shear the original image. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows from an affine transform matrix. """ - method = Image.AFFINE + method = Image.Transform.AFFINE + + +class PerspectiveTransform(Transform): + """ + Define a perspective image transform. + + This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel + (x, y) in the output image, the new value is taken from a position + ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in + the input image, rounded to nearest pixel. + + This function can be used to scale, translate, rotate, and shear the + original image. + + See :py:meth:`.Image.transform` + + :param matrix: An 8-tuple (a, b, c, d, e, f, g, h). + """ + + method = Image.Transform.PERSPECTIVE class ExtentTransform(Transform): @@ -63,13 +97,13 @@ class ExtentTransform(Transform): rectangle in the current image. It is slightly slower than crop, but about as fast as a corresponding resize operation. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the input image's coordinate system. See :ref:`coordinate-system`. """ - method = Image.EXTENT + method = Image.Transform.EXTENT class QuadTransform(Transform): @@ -79,14 +113,14 @@ class QuadTransform(Transform): Maps a quadrilateral (a region defined by four corners) from the image to a rectangle of the given size. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the upper left, lower left, lower right, and upper right corner of the source quadrilateral. """ - method = Image.QUAD + method = Image.Transform.QUAD class MeshTransform(Transform): @@ -94,9 +128,9 @@ class MeshTransform(Transform): Define a mesh image transform. A mesh transform consists of one or more individual quad transforms. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param data: A list of (bbox, quad) tuples. """ - method = Image.MESH + method = Image.Transform.MESH diff --git a/dependencies/OSX/PIL/ImageWin.py b/dependencies/OSX/PIL/ImageWin.py index ca9b14c..98c28f2 100644 --- a/dependencies/OSX/PIL/ImageWin.py +++ b/dependencies/OSX/PIL/ImageWin.py @@ -16,6 +16,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image @@ -27,10 +28,10 @@ class HDC: methods. """ - def __init__(self, dc): + def __init__(self, dc: int) -> None: self.dc = dc - def __int__(self): + def __int__(self) -> int: return self.dc @@ -41,10 +42,10 @@ class HWND: methods, instead of a DC. """ - def __init__(self, wnd): + def __init__(self, wnd: int) -> None: self.wnd = wnd - def __int__(self): + def __int__(self) -> int: return self.wnd @@ -54,9 +55,9 @@ class Dib: "L", "P", or "RGB". If the display requires a palette, this constructor creates a suitable - palette and associates it with the image. For an "L" image, 128 greylevels + palette and associates it with the image. For an "L" image, 128 graylevels are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together - with 20 greylevels. + with 20 graylevels. To make sure that palettes work properly under Windows, you must call the ``palette`` method upon certain events from Windows. @@ -68,22 +69,28 @@ class Dib: defines the size of the image. """ - def __init__(self, image, size=None): - if hasattr(image, "mode") and hasattr(image, "size"): + def __init__( + self, image: Image.Image | str, size: tuple[int, int] | None = None + ) -> None: + if isinstance(image, str): + mode = image + image = "" + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: mode = image.mode size = image.size - else: - mode = image - image = None if mode not in ["1", "L", "P", "RGB"]: mode = Image.getmodebase(mode) self.image = Image.core.display(mode, size) self.mode = mode self.size = size if image: + assert not isinstance(image, str) self.paste(image) - def expose(self, handle): + def expose(self, handle: int | HDC | HWND) -> None: """ Copy the bitmap contents to a device context. @@ -91,17 +98,22 @@ def expose(self, handle): HDC or HWND instance. In PythonWin, you can use ``CDC.GetHandleAttrib()`` to get a suitable handle. """ + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: - result = self.image.expose(dc) + self.image.expose(dc) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - result = self.image.expose(handle) - return result - - def draw(self, handle, dst, src=None): + self.image.expose(handle_int) + + def draw( + self, + handle: int | HDC | HWND, + dst: tuple[int, int, int, int], + src: tuple[int, int, int, int] | None = None, + ) -> None: """ Same as expose, but allows you to specify where to draw the image, and what part of it to draw. @@ -111,19 +123,19 @@ def draw(self, handle, dst, src=None): the destination have different sizes, the image is resized as necessary. """ - if not src: + if src is None: src = (0, 0) + self.size + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: - result = self.image.draw(dc, dst, src) + self.image.draw(dc, dst, src) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - result = self.image.draw(handle, dst, src) - return result + self.image.draw(handle_int, dst, src) - def query_palette(self, handle): + def query_palette(self, handle: int | HDC | HWND) -> int: """ Installs the palette associated with the image in the given device context. @@ -135,20 +147,23 @@ def query_palette(self, handle): :param handle: Device context (HDC), cast to a Python integer, or an HDC or HWND instance. - :return: A true value if one or more entries were changed (this - indicates that the image should be redrawn). + :return: The number of entries that were changed (if one or more entries, + this indicates that the image should be redrawn). """ + handle_int = int(handle) if isinstance(handle, HWND): - handle = self.image.getdc(handle) + handle = self.image.getdc(handle_int) try: result = self.image.query_palette(handle) finally: self.image.releasedc(handle, handle) else: - result = self.image.query_palette(handle) + result = self.image.query_palette(handle_int) return result - def paste(self, im, box=None): + def paste( + self, im: Image.Image, box: tuple[int, int, int, int] | None = None + ) -> None: """ Paste a PIL image into the bitmap image. @@ -168,16 +183,16 @@ def paste(self, im, box=None): else: self.image.paste(im.im) - def frombytes(self, buffer): + def frombytes(self, buffer: bytes) -> None: """ Load display memory contents from byte data. :param buffer: A buffer containing display data (usually data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`) """ - return self.image.frombytes(buffer) + self.image.frombytes(buffer) - def tobytes(self): + def tobytes(self) -> bytes: """ Copy display memory contents to bytes object. @@ -189,42 +204,44 @@ def tobytes(self): class Window: """Create a Window with the given title size.""" - def __init__(self, title="PIL", width=None, height=None): + def __init__( + self, title: str = "PIL", width: int | None = None, height: int | None = None + ) -> None: self.hwnd = Image.core.createwindow( title, self.__dispatcher, width or 0, height or 0 ) - def __dispatcher(self, action, *args): - return getattr(self, "ui_handle_" + action)(*args) + def __dispatcher(self, action: str, *args: int) -> None: + getattr(self, f"ui_handle_{action}")(*args) - def ui_handle_clear(self, dc, x0, y0, x1, y1): + def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_damage(self, x0, y0, x1, y1): + def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_destroy(self): + def ui_handle_destroy(self) -> None: pass - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_resize(self, width, height): + def ui_handle_resize(self, width: int, height: int) -> None: pass - def mainloop(self): + def mainloop(self) -> None: Image.core.eventloop() class ImageWindow(Window): """Create an image window which displays the given image.""" - def __init__(self, image, title="PIL"): + def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None: if not isinstance(image, Dib): image = Dib(image) self.image = image width, height = image.size super().__init__(title, width=width, height=height) - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: self.image.draw(dc, (x0, y0, x1, y1)) diff --git a/dependencies/OSX/PIL/ImtImagePlugin.py b/dependencies/OSX/PIL/ImtImagePlugin.py index 21ffd74..068cd5c 100644 --- a/dependencies/OSX/PIL/ImtImagePlugin.py +++ b/dependencies/OSX/PIL/ImtImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re @@ -22,7 +22,7 @@ # # -------------------------------------------------------------------- -field = re.compile(br"([a-z]*) ([^ \r\n]*)") +field = re.compile(rb"([a-z]*) ([^ \r\n]*)") ## @@ -30,41 +30,51 @@ class ImtImageFile(ImageFile.ImageFile): - format = "IMT" format_description = "IM Tools" - def _open(self): - + def _open(self) -> None: # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. - if b"\n" not in self.fp.read(100): - raise SyntaxError("not an IM file") - self.fp.seek(0) + assert self.fp is not None + + buffer = self.fp.read(100) + if b"\n" not in buffer: + msg = "not an IM file" + raise SyntaxError(msg) xsize = ysize = 0 while True: - - s = self.fp.read(1) + if buffer: + s = buffer[:1] + buffer = buffer[1:] + else: + s = self.fp.read(1) if not s: break if s == b"\x0C": - # image data begins self.tile = [ - ("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1)) + ImageFile._Tile( + "raw", + (0, 0) + self.size, + self.fp.tell() - len(buffer), + self.mode, + ) ] break else: - # read key/value pair - # FIXME: dangerous, may read whole file - s = s + self.fp.readline() + if b"\n" not in buffer: + buffer += self.fp.read(100) + lines = buffer.split(b"\n") + s += lines.pop(0) + buffer = b"\n".join(lines) if len(s) == 1 or len(s) > 100: break if s[0] == ord(b"*"): @@ -74,14 +84,14 @@ def _open(self): if not m: break k, v = m.group(1, 2) - if k == "width": + if k == b"width": xsize = int(v) self._size = xsize, ysize - elif k == "height": + elif k == b"height": ysize = int(v) self._size = xsize, ysize - elif k == "pixel" and v == "n8": - self.mode = "L" + elif k == b"pixel" and v == b"n8": + self._mode = "L" # diff --git a/dependencies/OSX/PIL/IptcImagePlugin.py b/dependencies/OSX/PIL/IptcImagePlugin.py index f407b7e..60ab7c8 100644 --- a/dependencies/OSX/PIL/IptcImagePlugin.py +++ b/dependencies/OSX/PIL/IptcImagePlugin.py @@ -14,31 +14,51 @@ # # See the README file for information on usage and redistribution. # -import os -import tempfile +from __future__ import annotations + +from collections.abc import Sequence +from io import BytesIO +from typing import cast from . import Image, ImageFile -from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 -from ._binary import o8 +from ._deprecate import deprecate COMPRESSION = {1: "raw", 5: "jpeg"} -PAD = o8(0) * 4 + +def __getattr__(name: str) -> bytes: + if name == "PAD": + deprecate("IptcImagePlugin.PAD", 12) + return b"\0\0\0\0" + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) # # Helpers -def i(c): - return i32((PAD + c)[-4:]) +def _i(c: bytes) -> int: + return i32((b"\0\0\0\0" + c)[-4:]) + + +def _i8(c: int | bytes) -> int: + return c if isinstance(c, int) else c[0] + + +def i(c: bytes) -> int: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.i", 12) + return _i(c) -def dump(c): +def dump(c: Sequence[int | bytes]) -> None: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.dump", 12) for i in c: - print("%02x" % i8(i), end=" ") + print(f"{_i8(i):02x}", end=" ") print() @@ -48,41 +68,41 @@ def dump(c): class IptcImageFile(ImageFile.ImageFile): - format = "IPTC" format_description = "IPTC/NAA" - def getint(self, key): - return i(self.info[key]) + def getint(self, key: tuple[int, int]) -> int: + return _i(self.info[key]) - def field(self): + def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header s = self.fp.read(5) - if not len(s): + if not s.strip(b"\x00"): return None, 0 - tag = i8(s[1]), i8(s[2]) + tag = s[1], s[2] # syntax - if i8(s[0]) != 0x1C or tag[0] < 1 or tag[0] > 9: - raise SyntaxError("invalid IPTC/NAA file") + if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]: + msg = "invalid IPTC/NAA file" + raise SyntaxError(msg) # field size - size = i8(s[3]) + size = s[3] if size > 132: - raise OSError("illegal field length in IPTC/NAA file") + msg = "illegal field length in IPTC/NAA file" + raise OSError(msg) elif size == 128: size = 0 elif size > 128: - size = i(self.fp.read(size - 128)) + size = _i(self.fp.read(size - 128)) else: - size = i16(s[3:]) + size = i16(s, 3) return tag, size - def _open(self): - + def _open(self) -> None: # load descriptive fields while True: offset = self.fp.tell() @@ -102,18 +122,18 @@ def _open(self): self.info[tag] = tagdata # mode - layers = i8(self.info[(3, 60)][0]) - component = i8(self.info[(3, 60)][1]) + layers = self.info[(3, 60)][0] + component = self.info[(3, 60)][1] if (3, 65) in self.info: - id = i8(self.info[(3, 65)][0]) - 1 + id = self.info[(3, 65)][0] - 1 else: id = 0 if layers == 1 and not component: - self.mode = "L" + self._mode = "L" elif layers == 3 and component: - self.mode = "RGB"[id] + self._mode = "RGB"[id] elif layers == 4 and component: - self.mode = "CMYK"[id] + self._mode = "CMYK"[id] # size self._size = self.getint((3, 20)), self.getint((3, 30)) @@ -122,32 +142,29 @@ def _open(self): try: compression = COMPRESSION[self.getint((3, 120))] except KeyError as e: - raise OSError("Unknown IPTC image compression") from e + msg = "Unknown IPTC image compression" + raise OSError(msg) from e # tile if tag == (8, 10): self.tile = [ - ("iptc", (compression, offset), (0, 0, self.size[0], self.size[1])) + ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression) ] - def load(self): - + def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) - type, tile, box = self.tile[0] - - encoding, offset = tile + offset, compression = self.tile[0][2:] self.fp.seek(offset) # Copy image data to temporary file - o_fd, outfile = tempfile.mkstemp(text=False) - o = os.fdopen(o_fd) - if encoding == "raw": + o = BytesIO() + if compression == "raw": # To simplify access to the extracted file, # prepend a PPM header - o.write("P5\n%d %d\n255\n" % self.size) + o.write(b"P5\n%d %d\n255\n" % self.size) while True: type, size = self.field() if type != (8, 10): @@ -158,17 +175,11 @@ def load(self): break o.write(s) size -= len(s) - o.close() - try: - with Image.open(outfile) as _im: - _im.load() - self.im = _im.im - finally: - try: - os.unlink(outfile) - except OSError: - pass + with Image.open(o) as _im: + _im.load() + self.im = _im.im + return None Image.register_open(IptcImageFile.format, IptcImageFile) @@ -176,7 +187,9 @@ def load(self): Image.register_extension(IptcImageFile.format, ".iim") -def getiptcinfo(im): +def getiptcinfo( + im: ImageFile.ImageFile, +) -> dict[tuple[int, int], bytes | list[bytes]] | None: """ Get IPTC information from TIFF, JPEG, or IPTC file. @@ -184,15 +197,17 @@ def getiptcinfo(im): :returns: A dictionary containing IPTC information, or None if no IPTC information block was found. """ - import io - from . import JpegImagePlugin, TiffImagePlugin data = None + info: dict[tuple[int, int], bytes | list[bytes]] = {} if isinstance(im, IptcImageFile): # return info dictionary right away - return im.info + for k, v in im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info elif isinstance(im, JpegImagePlugin.JpegImageFile): # extract the IPTC/NAA resource @@ -204,8 +219,8 @@ def getiptcinfo(im): # get raw data from the IPTC/NAA tag (PhotoShop tags the data # as 4-byte integers, so we cannot use the get method...) try: - data = im.tag.tagdata[TiffImagePlugin.IPTC_NAA_CHUNK] - except (AttributeError, KeyError): + data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK] + except KeyError: pass if data is None: @@ -215,16 +230,20 @@ def getiptcinfo(im): class FakeImage: pass - im = FakeImage() - im.__class__ = IptcImageFile + fake_im = FakeImage() + fake_im.__class__ = IptcImageFile # type: ignore[assignment] + iptc_im = cast(IptcImageFile, fake_im) # parse the IPTC information chunk - im.info = {} - im.fp = io.BytesIO(data) + iptc_im.info = {} + iptc_im.fp = BytesIO(data) try: - im._open() + iptc_im._open() except (IndexError, KeyError): pass # expected failure - return im.info + for k, v in iptc_im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info diff --git a/dependencies/OSX/PIL/Jpeg2KImagePlugin.py b/dependencies/OSX/PIL/Jpeg2KImagePlugin.py index 0b0d433..6782835 100644 --- a/dependencies/OSX/PIL/Jpeg2KImagePlugin.py +++ b/dependencies/OSX/PIL/Jpeg2KImagePlugin.py @@ -6,38 +6,113 @@ # # History: # 2014-03-12 ajh Created +# 2021-06-30 rogermb Extract dpi information from the 'resc' header box # # Copyright (c) 2014 Coriolis Systems Limited # Copyright (c) 2014 Alastair Houghton # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import os import struct +from collections.abc import Callable +from typing import IO, cast + +from . import Image, ImageFile, ImagePalette, _binary + + +class BoxReader: + """ + A small helper class to read fields stored in JPEG2000 header boxes + and to easily step into and read sub-boxes. + """ + + def __init__(self, fp: IO[bytes], length: int = -1) -> None: + self.fp = fp + self.has_length = length >= 0 + self.length = length + self.remaining_in_box = -1 + + def _can_read(self, num_bytes: int) -> bool: + if self.has_length and self.fp.tell() + num_bytes > self.length: + # Outside box: ensure we don't read past the known file length + return False + if self.remaining_in_box >= 0: + # Inside box contents: ensure read does not go past box boundaries + return num_bytes <= self.remaining_in_box + else: + return True # No length known, just read + + def _read_bytes(self, num_bytes: int) -> bytes: + if not self._can_read(num_bytes): + msg = "Not enough data in header" + raise SyntaxError(msg) + + data = self.fp.read(num_bytes) + if len(data) < num_bytes: + msg = f"Expected to read {num_bytes} bytes but only got {len(data)}." + raise OSError(msg) + + if self.remaining_in_box > 0: + self.remaining_in_box -= num_bytes + return data + + def read_fields(self, field_format: str) -> tuple[int | bytes, ...]: + size = struct.calcsize(field_format) + data = self._read_bytes(size) + return struct.unpack(field_format, data) + + def read_boxes(self) -> BoxReader: + size = self.remaining_in_box + data = self._read_bytes(size) + return BoxReader(io.BytesIO(data), size) + + def has_next_box(self) -> bool: + if self.has_length: + return self.fp.tell() + self.remaining_in_box < self.length + else: + return True -from . import Image, ImageFile + def next_box_type(self) -> bytes: + # Skip the rest of the box if it has not been read + if self.remaining_in_box > 0: + self.fp.seek(self.remaining_in_box, os.SEEK_CUR) + self.remaining_in_box = -1 + # Read the length and type of the next box + lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s")) + if lbox == 1: + lbox = cast(int, self.read_fields(">Q")[0]) + hlen = 16 + else: + hlen = 8 + + if lbox < hlen or not self._can_read(lbox - hlen): + msg = "Invalid header length" + raise SyntaxError(msg) + + self.remaining_in_box = lbox - hlen + return tbox -def _parse_codestream(fp): + +def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]: """Parse the JPEG 2000 codestream to extract the size and component count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" hdr = fp.read(2) - lsiz = struct.unpack(">H", hdr)[0] + lsiz = _binary.i16be(hdr) siz = hdr + fp.read(lsiz - 2) lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from( ">HHIIIIIIIIH", siz ) - ssiz = [None] * csiz - xrsiz = [None] * csiz - yrsiz = [None] * csiz - for i in range(csiz): - ssiz[i], xrsiz[i], yrsiz[i] = struct.unpack_from(">BBB", siz, 36 + 3 * i) size = (xsiz - xosiz, ysiz - yosiz) if csiz == 1: - if (yrsiz[0] & 0x7F) > 8: + ssiz = struct.unpack_from(">B", siz, 38) + if (ssiz[0] & 0x7F) + 1 > 8: mode = "I;16" else: mode = "L" @@ -48,106 +123,120 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None - - return (size, mode) - - -def _parse_jp2_header(fp): - """Parse the JP2 header box to extract size, component count and - color space information, returning a (size, mode, mimetype) tuple.""" + msg = "unable to determine J2K image mode" + raise SyntaxError(msg) + + return size, mode + + +def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: + """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, + calculated as (num / denom) * 10^exp and stored in dots per meter, + to floating-point dots per inch.""" + if denom == 0: + return None + return (254 * num * (10**exp)) / (10000 * denom) + + +def _parse_jp2_header( + fp: IO[bytes], +) -> tuple[ + tuple[int, int], + str, + str | None, + tuple[float, float] | None, + ImagePalette.ImagePalette | None, +]: + """Parse the JP2 header box to extract size, component count, + color space information, and optionally DPI information, + returning a (size, mode, mimetype, dpi) tuple.""" # Find the JP2 header box + reader = BoxReader(fp) header = None mimetype = None - while True: - lbox, tbox = struct.unpack(">I4s", fp.read(8)) - if lbox == 1: - lbox = struct.unpack(">Q", fp.read(8))[0] - hlen = 16 - else: - hlen = 8 - - if lbox < hlen: - raise SyntaxError("Invalid JP2 header length") + while reader.has_next_box(): + tbox = reader.next_box_type() if tbox == b"jp2h": - header = fp.read(lbox - hlen) + header = reader.read_boxes() break elif tbox == b"ftyp": - if fp.read(4) == b"jpx ": + if reader.read_fields(">4s")[0] == b"jpx ": mimetype = "image/jpx" - fp.seek(lbox - hlen - 4, os.SEEK_CUR) - else: - fp.seek(lbox - hlen, os.SEEK_CUR) - - if header is None: - raise SyntaxError("could not find JP2 header") + assert header is not None size = None mode = None bpc = None nc = None + dpi = None # 2-tuple of DPI info, or None + palette = None - hio = io.BytesIO(header) - while True: - lbox, tbox = struct.unpack(">I4s", hio.read(8)) - if lbox == 1: - lbox = struct.unpack(">Q", hio.read(8))[0] - hlen = 16 - else: - hlen = 8 - - content = hio.read(lbox - hlen) + while header.has_next_box(): + tbox = header.next_box_type() if tbox == b"ihdr": - height, width, nc, bpc, c, unkc, ipr = struct.unpack(">IIHBBBB", content) + height, width, nc, bpc = header.read_fields(">IIHB") + assert isinstance(height, int) + assert isinstance(width, int) + assert isinstance(bpc, int) size = (width, height) - if unkc: - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 2: - mode = "LA" - elif nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" - break - elif tbox == b"colr": - meth, prec, approx = struct.unpack_from(">BBB", content) - if meth == 1: - cs = struct.unpack_from(">I", content, 3)[0] - if cs == 16: # sRGB - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" - break - elif cs == 17: # grayscale - if nc == 1 and (bpc & 0x7F) > 8: - mode = "I;16" - elif nc == 1: - mode = "L" - elif nc == 2: - mode = "LA" - break - elif cs == 18: # sYCC - if nc == 3: - mode = "RGB" - elif nc == 4: - mode = "RGBA" + if nc == 1 and (bpc & 0x7F) > 8: + mode = "I;16" + elif nc == 1: + mode = "L" + elif nc == 2: + mode = "LA" + elif nc == 3: + mode = "RGB" + elif nc == 4: + mode = "RGBA" + elif tbox == b"colr" and nc == 4: + meth, _, _, enumcs = header.read_fields(">BBBI") + if meth == 1 and enumcs == 12: + mode = "CMYK" + elif tbox == b"pclr" and mode in ("L", "LA"): + ne, npc = header.read_fields(">HB") + assert isinstance(ne, int) + assert isinstance(npc, int) + max_bitdepth = 0 + for bitdepth in header.read_fields(">" + ("B" * npc)): + assert isinstance(bitdepth, int) + if bitdepth > max_bitdepth: + max_bitdepth = bitdepth + if max_bitdepth <= 8: + palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB") + for i in range(ne): + color: list[int] = [] + for value in header.read_fields(">" + ("B" * npc)): + assert isinstance(value, int) + color.append(value) + palette.getcolor(tuple(color)) + mode = "P" if mode == "L" else "PA" + elif tbox == b"res ": + res = header.read_boxes() + while res.has_next_box(): + tres = res.next_box_type() + if tres == b"resc": + vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") + assert isinstance(vrcn, int) + assert isinstance(vrcd, int) + assert isinstance(hrcn, int) + assert isinstance(hrcd, int) + assert isinstance(vrce, int) + assert isinstance(hrce, int) + hres = _res_to_dpi(hrcn, hrcd, hrce) + vres = _res_to_dpi(vrcn, vrcd, vrce) + if hres is not None and vres is not None: + dpi = (hres, vres) break if size is None or mode is None: - raise SyntaxError("Malformed jp2 header") + msg = "Malformed JP2 header" + raise SyntaxError(msg) - return (size, mode, mimetype) + return size, mode, mimetype, dpi, palette ## @@ -158,23 +247,29 @@ class Jpeg2KImageFile(ImageFile.ImageFile): format = "JPEG2000" format_description = "JPEG 2000 (ISO 15444)" - def _open(self): + def _open(self) -> None: sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" - self._size, self.mode = _parse_codestream(self.fp) + self._size, self._mode = _parse_codestream(self.fp) + self._parse_comment() else: sig = sig + self.fp.read(8) if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": self.codec = "jp2" header = _parse_jp2_header(self.fp) - self._size, self.mode, self.custom_mimetype = header + self._size, self._mode, self.custom_mimetype, dpi, self.palette = header + if dpi is not None: + self.info["dpi"] = dpi + if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + self.fp.seek(length - 2, os.SEEK_CUR) + self._parse_comment() else: - raise SyntaxError("not a JPEG 2000 file") - - if self.size is None or self.mode is None: - raise SyntaxError("unable to determine size/mode") + msg = "not a JPEG 2000 file" + raise SyntaxError(msg) self._reduce = 0 self.layers = 0 @@ -196,7 +291,7 @@ def _open(self): length = -1 self.tile = [ - ( + ImageFile._Tile( "jpeg2k", (0, 0) + self.size, 0, @@ -204,18 +299,41 @@ def _open(self): ) ] - @property - def reduce(self): + def _parse_comment(self) -> None: + while True: + marker = self.fp.read(2) + if not marker: + break + typ = marker[1] + if typ in (0x90, 0xD9): + # Start of tile or end of codestream + break + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + if typ == 0x64: + # Comment + self.info["comment"] = self.fp.read(length - 2)[2:] + break + else: + self.fp.seek(length - 2, os.SEEK_CUR) + + @property # type: ignore[override] + def reduce( + self, + ) -> ( + Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image] + | int + ): # https://github.com/python-pillow/Pillow/issues/4343 found that the # new Image 'reduce' method was shadowed by this plugin's 'reduce' # property. This attempts to allow for both scenarios return self._reduce or super().reduce @reduce.setter - def reduce(self, value): + def reduce(self, value: int) -> None: self._reduce = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self._reduce: power = 1 << self._reduce adjust = power >> 1 @@ -226,13 +344,14 @@ def load(self): # Update the reduce and layers settings t = self.tile[0] + assert isinstance(t[3], tuple) t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) - self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] + self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return ( prefix[:4] == b"\xff\x4f\xff\x51" or prefix[:12] == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" @@ -243,15 +362,17 @@ def _accept(prefix): # Save support -def _save(im, fp, filename): - if filename.endswith(".j2k"): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + # Get the keyword arguments + info = im.encoderinfo + + if isinstance(filename, str): + filename = filename.encode() + if filename.endswith(b".j2k") or info.get("no_jp2", False): kind = "j2k" else: kind = "jp2" - # Get the keyword arguments - info = im.encoderinfo - offset = info.get("offset", None) tile_offset = info.get("tile_offset", None) tile_size = info.get("tile_size", None) @@ -260,13 +381,11 @@ def _save(im, fp, filename): if quality_layers is not None and not ( isinstance(quality_layers, (list, tuple)) and all( - [ - isinstance(quality_layer, (int, float)) - for quality_layer in quality_layers - ] + isinstance(quality_layer, (int, float)) for quality_layer in quality_layers ) ): - raise ValueError("quality_layers must be a sequence of numbers") + msg = "quality_layers must be a sequence of numbers" + raise ValueError(msg) num_resolutions = info.get("num_resolutions", 0) cblk_size = info.get("codeblock_size", None) @@ -274,8 +393,14 @@ def _save(im, fp, filename): irreversible = info.get("irreversible", False) progression = info.get("progression", "LRCP") cinema_mode = info.get("cinema_mode", "no") - fd = -1 + mct = info.get("mct", 0) + signed = info.get("signed", False) + comment = info.get("comment") + if isinstance(comment, str): + comment = comment.encode() + plt = info.get("plt", False) + fd = -1 if hasattr(fp, "fileno"): try: fd = fp.fileno() @@ -294,10 +419,14 @@ def _save(im, fp, filename): irreversible, progression, cinema_mode, + mct, + signed, fd, + comment, + plt, ) - ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) + ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)]) # ------------------------------------------------------------ diff --git a/dependencies/OSX/PIL/JpegImagePlugin.py b/dependencies/OSX/PIL/JpegImagePlugin.py index 29bc61a..457690a 100644 --- a/dependencies/OSX/PIL/JpegImagePlugin.py +++ b/dependencies/OSX/PIL/JpegImagePlugin.py @@ -31,32 +31,40 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import array import io +import math import os import struct import subprocess import sys import tempfile import warnings +from typing import IO, TYPE_CHECKING, Any -from . import Image, ImageFile, TiffImagePlugin -from ._binary import i8 +from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 +from ._binary import o16be as o16 +from ._deprecate import deprecate from .JpegPresets import presets +if TYPE_CHECKING: + from .MpoImagePlugin import MpoImageFile + # # Parser -def Skip(self, marker): +def Skip(self: JpegImageFile, marker: int) -> None: n = i16(self.fp.read(2)) - 2 ImageFile._safe_read(self.fp, n) -def APP(self, marker): +def APP(self: JpegImageFile, marker: int) -> None: # # Application marker. Store these in the APP dictionary. # Also look for well-known application markers. @@ -64,7 +72,7 @@ def APP(self, marker): n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) - app = "APP%d" % (marker & 15) + app = f"APP{marker & 15}" self.app[app] = s # compatibility self.applist.append((app, s)) @@ -75,19 +83,27 @@ def APP(self, marker): self.info["jfif_version"] = divmod(version, 256) # extract JFIF properties try: - jfif_unit = i8(s[7]) + jfif_unit = s[7] jfif_density = i16(s, 8), i16(s, 10) except Exception: pass else: if jfif_unit == 1: self.info["dpi"] = jfif_density + elif jfif_unit == 2: # cm + # 1 dpcm = 2.54 dpi + self.info["dpi"] = tuple(d * 2.54 for d in jfif_density) self.info["jfif_unit"] = jfif_unit self.info["jfif_density"] = jfif_density - elif marker == 0xFFE1 and s[:5] == b"Exif\0": - if "exif" not in self.info: - # extract EXIF information (incomplete) - self.info["exif"] = s # FIXME: value will change + elif marker == 0xFFE1 and s[:6] == b"Exif\0\0": + # extract EXIF information + if "exif" in self.info: + self.info["exif"] += s[6:] + else: + self.info["exif"] = s + self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00", 1)[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -115,7 +131,7 @@ def APP(self, marker): code = i16(s, offset) offset += 2 # resource name (usually empty) - name_len = i8(s[offset]) + name_len = s[offset] # name = s[offset+1:offset+1+name_len] offset += 1 + name_len offset += offset & 1 # align @@ -124,13 +140,14 @@ def APP(self, marker): offset += 4 data = s[offset : offset + size] if code == 0x03ED: # ResolutionInfo - data = { - "XResolution": i32(data[:4]) / 65536, - "DisplayedUnitsX": i16(data[4:8]), - "YResolution": i32(data[8:12]) / 65536, - "DisplayedUnitsY": i16(data[12:]), + photoshop[code] = { + "XResolution": i32(data, 0) / 65536, + "DisplayedUnitsX": i16(data, 4), + "YResolution": i32(data, 8) / 65536, + "DisplayedUnitsY": i16(data, 12), } - photoshop[code] = data + else: + photoshop[code] = data offset += size offset += offset & 1 # align except struct.error: @@ -140,8 +157,8 @@ def APP(self, marker): self.info["adobe"] = i16(s, 5) # extract Adobe custom properties try: - adobe_transform = i8(s[1]) - except Exception: + adobe_transform = s[11] + except IndexError: pass else: self.info["adobe_transform"] = adobe_transform @@ -152,29 +169,8 @@ def APP(self, marker): # plus constant header size self.info["mpoffset"] = self.fp.tell() - n + 4 - # If DPI isn't in JPEG header, fetch from EXIF - if "dpi" not in self.info and "exif" in self.info: - try: - exif = self.getexif() - resolution_unit = exif[0x0128] - x_resolution = exif[0x011A] - try: - dpi = float(x_resolution[0]) / x_resolution[1] - except TypeError: - dpi = x_resolution - if resolution_unit == 3: # cm - # 1 dpcm = 2.54 dpi - dpi *= 2.54 - self.info["dpi"] = int(dpi + 0.5), int(dpi + 0.5) - except (KeyError, SyntaxError, ValueError, ZeroDivisionError): - # SyntaxError for invalid/unreadable EXIF - # KeyError for dpi not included - # ZeroDivisionError for invalid dpi rational value - # ValueError for x_resolution[0] being an invalid float - self.info["dpi"] = 72, 72 - -def COM(self, marker): +def COM(self: JpegImageFile, marker: int) -> None: # # Comment marker. Store these in the APP dictionary. n = i16(self.fp.read(2)) - 2 @@ -185,7 +181,7 @@ def COM(self, marker): self.applist.append(("COM", s)) -def SOF(self, marker): +def SOF(self: JpegImageFile, marker: int) -> None: # # Start of frame marker. Defines the size and mode of the # image. JPEG is colour blind, so we use some simple @@ -195,21 +191,23 @@ def SOF(self, marker): n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) - self._size = i16(s[3:]), i16(s[1:]) + self._size = i16(s, 3), i16(s, 1) - self.bits = i8(s[0]) + self.bits = s[0] if self.bits != 8: - raise SyntaxError(f"cannot handle {self.bits}-bit layers") + msg = f"cannot handle {self.bits}-bit layers" + raise SyntaxError(msg) - self.layers = i8(s[5]) + self.layers = s[5] if self.layers == 1: - self.mode = "L" + self._mode = "L" elif self.layers == 3: - self.mode = "RGB" + self._mode = "RGB" elif self.layers == 4: - self.mode = "CMYK" + self._mode = "CMYK" else: - raise SyntaxError(f"cannot handle {self.layers}-layer images") + msg = f"cannot handle {self.layers}-layer images" + raise SyntaxError(msg) if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]: self.info["progressive"] = self.info["progression"] = 1 @@ -217,10 +215,8 @@ def SOF(self, marker): if self.icclist: # fixup icc profile self.icclist.sort() # sort by sequence number - if i8(self.icclist[0][13]) == len(self.icclist): - profile = [] - for p in self.icclist: - profile.append(p[14:]) + if self.icclist[0][13] == len(self.icclist): + profile = [p[14:] for p in self.icclist] icc_profile = b"".join(profile) else: icc_profile = None # wrong number of fragments @@ -230,10 +226,10 @@ def SOF(self, marker): for i in range(6, len(s), 3): t = s[i : i + 3] # 4-tuples: id, vsamp, hsamp, qtable - self.layer.append((t[0], i8(t[1]) // 16, i8(t[1]) & 15, i8(t[2]))) + self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2])) -def DQT(self, marker): +def DQT(self: JpegImageFile, marker: int) -> None: # # Define quantization table. Note that there might be more # than one table in each marker. @@ -244,15 +240,16 @@ def DQT(self, marker): n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) while len(s): - v = i8(s[0]) + v = s[0] precision = 1 if (v // 16 == 0) else 2 # in bytes qt_length = 1 + precision * 64 if len(s) < qt_length: - raise SyntaxError("bad quantization table marker") + msg = "bad quantization table marker" + raise SyntaxError(msg) data = array.array("B" if precision == 1 else "H", s[1:qt_length]) if sys.byteorder == "little" and precision > 1: data.byteswap() # the values are always big-endian - self.quantization[v & 15] = data + self.quantization[v & 15] = [data[i] for i in zigzag_index] s = s[qt_length:] @@ -326,9 +323,9 @@ def DQT(self, marker): } -def _accept(prefix): +def _accept(prefix: bytes) -> bool: # Magic number was taken from https://en.wikipedia.org/wiki/JPEG - return prefix[0:3] == b"\xFF\xD8\xFF" + return prefix[:3] == b"\xFF\xD8\xFF" ## @@ -336,33 +333,32 @@ def _accept(prefix): class JpegImageFile(ImageFile.ImageFile): - format = "JPEG" format_description = "JPEG (ISO 10918)" - def _open(self): - + def _open(self) -> None: s = self.fp.read(3) if not _accept(s): - raise SyntaxError("not a JPEG file") + msg = "not a JPEG file" + raise SyntaxError(msg) s = b"\xFF" # Create attributes self.bits = self.layers = 0 + self._exif_offset = 0 # JPEG specifics (internal) - self.layer = [] - self.huffman_dc = {} - self.huffman_ac = {} - self.quantization = {} - self.app = {} # compatibility - self.applist = [] - self.icclist = [] + self.layer: list[tuple[int, int, int, int]] = [] + self._huffman_dc: dict[Any, Any] = {} + self._huffman_ac: dict[Any, Any] = {} + self.quantization: dict[int, list[int]] = {} + self.app: dict[str, bytes] = {} # compatibility + self.applist: list[tuple[str, bytes]] = [] + self.icclist: list[bytes] = [] while True: - - i = i8(s) + i = s[0] if i == 0xFF: s = s + self.fp.read(1) i = i16(s) @@ -379,19 +375,37 @@ def _open(self): rawmode = self.mode if self.mode == "CMYK": rawmode = "CMYK;I" # assume adobe conventions - self.tile = [("jpeg", (0, 0) + self.size, 0, (rawmode, ""))] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, "")) + ] # self.__offset = self.fp.tell() break s = self.fp.read(1) - elif i == 0 or i == 0xFFFF: + elif i in {0, 0xFFFF}: # padded marker or junk; move on s = b"\xff" elif i == 0xFF00: # Skip extraneous data (escaped 0xFF) s = self.fp.read(1) else: - raise SyntaxError("no marker found") + msg = "no marker found" + raise SyntaxError(msg) - def load_read(self, read_bytes): + self._read_dpi_from_exif() + + def __getattr__(self, name: str) -> Any: + if name in ("huffman_ac", "huffman_dc"): + deprecate(name, 12) + return getattr(self, "_" + name) + raise AttributeError(name) + + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.layers, self.layer] + + def __setstate__(self, state: list[Any]) -> None: + super().__setstate__(state) + self.layers, self.layer = state[5:] + + def load_read(self, read_bytes: int) -> bytes: """ internal: read more image data For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker @@ -399,28 +413,31 @@ def load_read(self, read_bytes): """ s = self.fp.read(read_bytes) - if not s and ImageFile.LOAD_TRUNCATED_IMAGES: + if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"): # Premature EOF. # Pretend file is finished adding EOI marker + self._ended = True return b"\xFF\xD9" return s - def draft(self, mode, size): - + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: - return + return None # Protect from second call if self.decoderconfig: - return + return None d, e, o, a = self.tile[0] scale = 1 original_size = self.size + assert isinstance(a, tuple) if a[0] == "RGB" and mode in ["L", "YCbCr"]: - self.mode = mode + self._mode = mode a = mode, "" if size: @@ -428,6 +445,7 @@ def draft(self, mode, size): for s in [8, 4, 2, 1]: if scale >= s: break + assert e is not None e = ( e[0], e[1], @@ -437,14 +455,13 @@ def draft(self, mode, size): self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) scale = s - self.tile = [(d, e, o, a)] + self.tile = [ImageFile._Tile(d, e, o, a)] self.decoderconfig = (scale, 0) box = (0, 0, original_size[0] / scale, original_size[1] / scale) - return (self.mode, box) - - def load_djpeg(self): + return self.mode, box + def load_djpeg(self) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities f, path = tempfile.mkstemp() @@ -452,7 +469,13 @@ def load_djpeg(self): if os.path.exists(self.filename): subprocess.check_call(["djpeg", "-outfile", path, self.filename]) else: - raise ValueError("Invalid Filename") + try: + os.unlink(path) + except OSError: + pass + + msg = "Invalid Filename" + raise ValueError(msg) try: with Image.open(path) as _im: @@ -464,25 +487,54 @@ def load_djpeg(self): except OSError: pass - self.mode = self.im.mode + self._mode = self.im.mode self._size = self.im.size self.tile = [] - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: return _getexif(self) - def _getmp(self): + def _read_dpi_from_exif(self) -> None: + # If DPI isn't in JPEG header, fetch from EXIF + if "dpi" in self.info or "exif" not in self.info: + return + try: + exif = self.getexif() + resolution_unit = exif[0x0128] + x_resolution = exif[0x011A] + try: + dpi = float(x_resolution[0]) / x_resolution[1] + except TypeError: + dpi = x_resolution + if math.isnan(dpi): + msg = "DPI is not a number" + raise ValueError(msg) + if resolution_unit == 3: # cm + # 1 dpcm = 2.54 dpi + dpi *= 2.54 + self.info["dpi"] = dpi, dpi + except ( + struct.error, # truncated EXIF + KeyError, # dpi not included + SyntaxError, # invalid/unreadable EXIF + TypeError, # dpi is an invalid float + ValueError, # dpi is an invalid float + ZeroDivisionError, # invalid dpi rational value + ): + self.info["dpi"] = 72, 72 + + def _getmp(self) -> dict[int, Any] | None: return _getmp(self) -def _getexif(self): +def _getexif(self: JpegImageFile) -> dict[int, Any] | None: if "exif" not in self.info: return None - return dict(self.getexif()) + return self.getexif()._get_merged_dict() -def _getmp(self): +def _getmp(self: JpegImageFile) -> dict[int, Any] | None: # Extract MP information. This method was inspired by the "highly # experimental" _getexif version that's been in use for years now, # itself based on the ImageFileDirectory class in the TIFF plugin. @@ -497,18 +549,22 @@ def _getmp(self): head = file_contents.read(8) endianness = ">" if head[:4] == b"\x4d\x4d\x00\x2a" else "<" # process dictionary + from . import TiffImagePlugin + try: info = TiffImagePlugin.ImageFileDirectory_v2(head) file_contents.seek(info.next) info.load(file_contents) mp = dict(info) except Exception as e: - raise SyntaxError("malformed MP Index (unreadable directory)") from e + msg = "malformed MP Index (unreadable directory)" + raise SyntaxError(msg) from e # it's an error not to have a number of images try: quant = mp[0xB001] except KeyError as e: - raise SyntaxError("malformed MP Index (no number of images)") from e + msg = "malformed MP Index (no number of images)" + raise SyntaxError(msg) from e # get MP entries mpentries = [] try: @@ -530,7 +586,8 @@ def _getmp(self): if mpentryattr["ImageDataFormat"] == 0: mpentryattr["ImageDataFormat"] = "JPEG" else: - raise SyntaxError("unsupported picture format in MPO") + msg = "unsupported picture format in MPO" + raise SyntaxError(msg) mptypemap = { 0x000000: "Undefined", 0x010001: "Large Thumbnail (VGA Equivalent)", @@ -545,7 +602,8 @@ def _getmp(self): mpentries.append(mpentry) mp[0xB002] = mpentries except KeyError as e: - raise SyntaxError("malformed MP Index (bad MP Entry)") from e + msg = "malformed MP Index (bad MP Entry)" + raise SyntaxError(msg) from e # Next we should try and parse the individual image unique ID list; # we don't because I've never seen this actually used in a real MPO # file and so can't test it. @@ -584,14 +642,7 @@ def _getmp(self): # fmt: on -def convert_dict_qtables(qtables): - qtables = [qtables[key] for key in range(len(qtables)) if key in qtables] - for idx, table in enumerate(qtables): - qtables[idx] = [table[i] for i in zigzag_index] - return qtables - - -def get_sampling(im): +def get_sampling(im: Image.Image) -> int: # There's no subsampling when images have only 1 layer # (grayscale images) or when they are CMYK (4 layers), # so set subsampling to the default value. @@ -599,18 +650,22 @@ def get_sampling(im): # NOTE: currently Pillow can't encode JPEG to YCCK format. # If YCCK support is added in the future, subsampling code will have # to be updated (here and in JpegEncode.c) to deal with 4 layers. - if not hasattr(im, "layers") or im.layers in (1, 4): + if not isinstance(im, JpegImageFile) or im.layers in (1, 4): return -1 sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] return samplings.get(sampling, -1) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.width == 0 or im.height == 0: + msg = "cannot write empty image as JPEG" + raise ValueError(msg) try: rawmode = RAWMODE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as JPEG") from e + msg = f"cannot write mode {im.mode} as JPEG" + raise OSError(msg) from e info = im.encoderinfo @@ -630,7 +685,8 @@ def _save(im, fp, filename): subsampling = preset.get("subsampling", -1) qtables = preset.get("quantization") elif not isinstance(quality, int): - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) else: if subsampling in presets: subsampling = presets[subsampling].get("subsampling", -1) @@ -649,10 +705,15 @@ def _save(im, fp, filename): subsampling = 2 elif subsampling == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) subsampling = get_sampling(im) - def validate_qtables(qtables): + def validate_qtables( + qtables: ( + str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None + ) + ) -> list[list[int]] | None: if qtables is None: return qtables if isinstance(qtables, str): @@ -663,47 +724,64 @@ def validate_qtables(qtables): for num in line.split("#", 1)[0].split() ] except ValueError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)] if isinstance(qtables, (tuple, list, dict)): if isinstance(qtables, dict): - qtables = convert_dict_qtables(qtables) + qtables = [ + qtables[key] for key in range(len(qtables)) if key in qtables + ] elif isinstance(qtables, tuple): qtables = list(qtables) if not (0 < len(qtables) < 5): - raise ValueError("None or too many quantization tables") + msg = "None or too many quantization tables" + raise ValueError(msg) for idx, table in enumerate(qtables): try: if len(table) != 64: - raise TypeError - table = array.array("H", table) + msg = "Invalid quantization table" + raise TypeError(msg) + table_array = array.array("H", table) except TypeError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: - qtables[idx] = list(table) + qtables[idx] = list(table_array) return qtables if qtables == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) qtables = getattr(im, "quantization", None) qtables = validate_qtables(qtables) - extra = b"" + extra = info.get("extra", b"") + + MAX_BYTES_IN_MARKER = 65533 + xmp = info.get("xmp") + if xmp: + overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + if len(xmp) > max_data_bytes_in_marker: + msg = "XMP data is too long" + raise ValueError(msg) + size = o16(2 + overhead_len + len(xmp)) + extra += b"\xFF\xE1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp icc_profile = info.get("icc_profile") if icc_profile: - ICC_OVERHEAD_LEN = 14 - MAX_BYTES_IN_MARKER = 65533 - MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN + overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] while icc_profile: - markers.append(icc_profile[:MAX_DATA_BYTES_IN_MARKER]) - icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:] + markers.append(icc_profile[:max_data_bytes_in_marker]) + icc_profile = icc_profile[max_data_bytes_in_marker:] i = 1 for marker in markers: - size = struct.pack(">H", 2 + ICC_OVERHEAD_LEN + len(marker)) + size = o16(2 + overhead_len + len(marker)) extra += ( b"\xFF\xE2" + size @@ -714,6 +792,8 @@ def validate_qtables(qtables): ) i += 1 + comment = info.get("comment", im.info.get("comment")) + # "progressive" is the official name, but older documentation # says "progression" # FIXME: issue a warning if the wrong form is used (post-1.1.7) @@ -724,6 +804,9 @@ def validate_qtables(qtables): exif = info.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if len(exif) > MAX_BYTES_IN_MARKER: + msg = "EXIF data is too long" + raise ValueError(msg) # get keyword arguments im.encoderconfig = ( @@ -731,11 +814,15 @@ def validate_qtables(qtables): progressive, info.get("smooth", 0), optimize, + info.get("keep_rgb", False), info.get("streamtype", 0), dpi[0], dpi[1], subsampling, + info.get("restart_marker_blocks", 0), + info.get("restart_marker_rows", 0), qtables, + comment, extra, exif, ) @@ -754,15 +841,21 @@ def validate_qtables(qtables): bufsize = 2 * im.size[0] * im.size[1] else: bufsize = im.size[0] * im.size[1] + if exif: + bufsize += len(exif) + 5 + if extra: + bufsize += len(extra) + 1 + else: + # The EXIF info needs to be written as one block, + APP1, + one spare byte. + # Ensure that our buffer is big enough. Same with the icc_profile block. + bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) - # The EXIF info needs to be written as one block, + APP1, + one spare byte. - # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(ImageFile.MAXBLOCK, bufsize, len(exif) + 5, len(extra) + 1) - - ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) + ImageFile._save( + im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize + ) -def _save_cjpeg(im, fp, filename): +def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities. tempfile = im._dump() subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) @@ -774,11 +867,17 @@ def _save_cjpeg(im, fp, filename): ## # Factory for making JPEG and MPO instances -def jpeg_factory(fp=None, filename=None): +def jpeg_factory( + fp: IO[bytes], filename: str | bytes | None = None +) -> JpegImageFile | MpoImageFile: im = JpegImageFile(fp, filename) try: mpheader = im._getmp() - if mpheader[45057] > 1: + if mpheader is not None and mpheader[45057] > 1: + for segment, content in im.applist: + if segment == "APP1" and b' hdrgm:Version="' in content: + # Ultra HDR images are not yet supported + return im # It's actually an MPO from .MpoImagePlugin import MpoImageFile diff --git a/dependencies/OSX/PIL/JpegPresets.py b/dependencies/OSX/PIL/JpegPresets.py index 79d10eb..d0e64a3 100644 --- a/dependencies/OSX/PIL/JpegPresets.py +++ b/dependencies/OSX/PIL/JpegPresets.py @@ -37,7 +37,7 @@ :func:`.JpegImagePlugin.get_sampling` function. In JPEG compressed data a JPEG marker is used instead of an EXIF tag. -(ref.: https://www.exiv2.org/tags.html) +(ref.: https://exiv2.org/tags.html) Quantization tables @@ -52,25 +52,19 @@ im.quantization -This will return a dict with a number of arrays. You can pass this dict +This will return a dict with a number of lists. You can pass this dict directly as the qtables argument when saving a JPEG. -The tables format between im.quantization and quantization in presets differ in -3 ways: - -1. The base container of the preset is a list with sublists instead of dict. - dict[0] -> list[0], dict[1] -> list[1], ... -2. Each table in a preset is a list instead of an array. -3. The zigzag order is remove in the preset (needed by libjpeg >= 6a). - -You can convert the dict format to the preset format with the -:func:`.JpegImagePlugin.convert_dict_qtables()` function. +The quantization table format in presets is a list with sublists. These formats +are interchangeable. Libjpeg ref.: https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html """ +from __future__ import annotations + # fmt: off presets = { 'web_low': {'subsampling': 2, # "4:2:0" diff --git a/dependencies/OSX/PIL/McIdasImagePlugin.py b/dependencies/OSX/PIL/McIdasImagePlugin.py index cd047fe..5dd031b 100644 --- a/dependencies/OSX/PIL/McIdasImagePlugin.py +++ b/dependencies/OSX/PIL/McIdasImagePlugin.py @@ -15,14 +15,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import struct from . import Image, ImageFile -def _accept(s): - return s[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" +def _accept(prefix: bytes) -> bool: + return prefix[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" ## @@ -30,16 +31,17 @@ def _accept(s): class McIdasImageFile(ImageFile.ImageFile): - format = "MCIDAS" format_description = "McIdas area file" - def _open(self): - + def _open(self) -> None: # parse area file directory + assert self.fp is not None + s = self.fp.read(256) if not _accept(s) or len(s) != 256: - raise SyntaxError("not an McIdas area file") + msg = "not an McIdas area file" + raise SyntaxError(msg) self.area_descriptor_raw = s self.area_descriptor = w = [0] + list(struct.unpack("!64i", s)) @@ -56,15 +58,18 @@ def _open(self): mode = "I" rawmode = "I;32B" else: - raise SyntaxError("unsupported McIdas format") + msg = "unsupported McIdas format" + raise SyntaxError(msg) - self.mode = mode + self._mode = mode self._size = w[10], w[9] offset = w[34] + w[15] stride = w[15] + w[10] * w[11] * w[14] - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) + ] # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/MicImagePlugin.py b/dependencies/OSX/PIL/MicImagePlugin.py index 2aed260..5f23a34 100644 --- a/dependencies/OSX/PIL/MicImagePlugin.py +++ b/dependencies/OSX/PIL/MicImagePlugin.py @@ -15,7 +15,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import olefile @@ -25,7 +25,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == olefile.MAGIC @@ -34,51 +34,50 @@ def _accept(prefix): class MicImageFile(TiffImagePlugin.TiffImageFile): - format = "MIC" format_description = "Microsoft Image Composer" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # read the OLE directory and see if this is a likely # to be a Microsoft Image Composer file try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an MIC file; invalid OLE file") from e + msg = "not an MIC file; invalid OLE file" + raise SyntaxError(msg) from e # find ACI subfiles with Image members (maybe not the # best way to identify MIC files, but what the... ;-) - self.images = [] - for path in self.ole.listdir(): - if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image": - self.images.append(path) + self.images = [ + path + for path in self.ole.listdir() + if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image" + ] # if we didn't find any images, this is probably not # an MIC file. if not self.images: - raise SyntaxError("not an MIC file; no image entries") + msg = "not an MIC file; no image entries" + raise SyntaxError(msg) - self.__fp = self.fp - self.frame = None + self.frame = -1 self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 - if len(self.images) > 1: - self.category = Image.CONTAINER - + self.__fp = self.fp self.seek(0) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return try: filename = self.images[frame] except IndexError as e: - raise EOFError("no such frame") from e + msg = "no such frame" + raise EOFError(msg) from e self.fp = self.ole.openstream(filename) @@ -86,17 +85,18 @@ def seek(self, frame): self.frame = frame - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None + def close(self) -> None: + self.__fp.close() + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.__fp.close() + self.ole.close() + super().__exit__() # diff --git a/dependencies/OSX/PIL/MpegImagePlugin.py b/dependencies/OSX/PIL/MpegImagePlugin.py index a358dfd..ad4d3e9 100644 --- a/dependencies/OSX/PIL/MpegImagePlugin.py +++ b/dependencies/OSX/PIL/MpegImagePlugin.py @@ -12,25 +12,26 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile from ._binary import i8 +from ._typing import SupportsRead # # Bitstream parser class BitStream: - def __init__(self, fp): + def __init__(self, fp: SupportsRead[bytes]) -> None: self.fp = fp self.bits = 0 self.bitbuffer = 0 - def next(self): + def next(self) -> int: return i8(self.fp.read(1)) - def peek(self, bits): + def peek(self, bits: int) -> int: while self.bits < bits: c = self.next() if c < 0: @@ -40,43 +41,47 @@ def peek(self, bits): self.bits += 8 return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1 - def skip(self, bits): + def skip(self, bits: int) -> None: while self.bits < bits: self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1)) self.bits += 8 self.bits = self.bits - bits - def read(self, bits): + def read(self, bits: int) -> int: v = self.peek(bits) self.bits = self.bits - bits return v +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"\x00\x00\x01\xb3" + + ## # Image plugin for MPEG streams. This plugin can identify a stream, # but it cannot read it. class MpegImageFile(ImageFile.ImageFile): - format = "MPEG" format_description = "MPEG" - def _open(self): + def _open(self) -> None: + assert self.fp is not None s = BitStream(self.fp) - if s.read(32) != 0x1B3: - raise SyntaxError("not an MPEG file") + msg = "not an MPEG file" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = s.read(12), s.read(12) # -------------------------------------------------------------------- # Registry stuff -Image.register_open(MpegImageFile.format, MpegImageFile) +Image.register_open(MpegImageFile.format, MpegImageFile, _accept) Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"]) diff --git a/dependencies/OSX/PIL/MpoImagePlugin.py b/dependencies/OSX/PIL/MpoImagePlugin.py index 575cc9c..71f89a0 100644 --- a/dependencies/OSX/PIL/MpoImagePlugin.py +++ b/dependencies/OSX/PIL/MpoImagePlugin.py @@ -17,18 +17,75 @@ # # See the README file for information on usage and redistribution. # - -from . import Image, ImageFile, JpegImagePlugin -from ._binary import i16be as i16 - - -def _accept(prefix): - return JpegImagePlugin._accept(prefix) - - -def _save(im, fp, filename): - # Note that we can only save the current frame at present - return JpegImagePlugin._save(im, fp, filename) +from __future__ import annotations + +import itertools +import os +import struct +from typing import IO, Any, cast + +from . import ( + Image, + ImageFile, + ImageSequence, + JpegImagePlugin, + TiffImagePlugin, +) +from ._binary import o32le + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + JpegImagePlugin._save(im, fp, filename) + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + append_images = im.encoderinfo.get("append_images", []) + if not append_images and not getattr(im, "is_animated", False): + _save(im, fp, filename) + return + + mpf_offset = 28 + offsets: list[int] = [] + for imSequence in itertools.chain([im], append_images): + for im_frame in ImageSequence.Iterator(imSequence): + if not offsets: + # APP2 marker + im_frame.encoderinfo["extra"] = ( + b"\xFF\xE2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82 + ) + exif = im_frame.encoderinfo.get("exif") + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + im_frame.encoderinfo["exif"] = exif + if exif: + mpf_offset += 4 + len(exif) + + JpegImagePlugin._save(im_frame, fp, filename) + offsets.append(fp.tell()) + else: + im_frame.save(fp, "JPEG") + offsets.append(fp.tell() - offsets[-1]) + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[0xB000] = b"0100" + ifd[0xB001] = len(offsets) + + mpentries = b"" + data_offset = 0 + for i, size in enumerate(offsets): + if i == 0: + mptype = 0x030000 # Baseline MP Primary Image + else: + mptype = 0x000000 # Undefined + mpentries += struct.pack(" None: self.fp.seek(0) # prep the fp in order to pass the JPEG test JpegImagePlugin.JpegImageFile._open(self) self._after_jpeg_open() - def _after_jpeg_open(self, mpheader=None): + def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None: self.mpinfo = mpheader if mpheader is not None else self._getmp() + if self.mpinfo is None: + msg = "Image appears to be a malformed MPO file" + raise ValueError(msg) self.n_frames = self.mpinfo[0xB001] self.__mpoffsets = [ mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002] @@ -58,53 +117,48 @@ def _after_jpeg_open(self, mpheader=None): assert self.n_frames == len(self.__mpoffsets) del self.info["mpoffset"] # no longer needed self.is_animated = self.n_frames > 1 - self.__fp = self.fp # FIXME: hack - self.__fp.seek(self.__mpoffsets[0]) # get ready to read first frame + self._fp = self.fp # FIXME: hack + self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame self.__frame = 0 self.offset = 0 # for now we can only handle reading and individual frame extraction self.readonly = 1 - def load_seek(self, pos): - self.__fp.seek(pos) + def load_seek(self, pos: int) -> None: + self._fp.seek(pos) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return - self.fp = self.__fp + self.fp = self._fp self.offset = self.__mpoffsets[frame] - self.fp.seek(self.offset + 2) # skip SOI marker - segment = self.fp.read(2) - if not segment: - raise ValueError("No data found for frame") - if i16(segment) == 0xFFE1: # APP1 - n = i16(self.fp.read(2)) - 2 - self.info["exif"] = ImageFile._safe_read(self.fp, n) - - exif = self.getexif() - if 40962 in exif and 40963 in exif: - self._size = (exif[40962], exif[40963]) - elif "exif" in self.info: + original_exif = self.info.get("exif") + if "exif" in self.info: del self.info["exif"] - self.tile = [("jpeg", (0, 0) + self.size, self.offset, (self.mode, ""))] + self.fp.seek(self.offset + 2) # skip SOI marker + if not self.fp.read(2): + msg = "No data found for frame" + raise ValueError(msg) + self.fp.seek(self.offset) + JpegImagePlugin.JpegImageFile._open(self) + if self.info.get("exif") != original_exif: + self._reload_exif() + + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1]) + ] self.__frame = frame - def tell(self): + def tell(self) -> int: return self.__frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - @staticmethod - def adopt(jpeg_instance, mpheader=None): + def adopt( + jpeg_instance: JpegImagePlugin.JpegImageFile, + mpheader: dict[int, Any] | None = None, + ) -> MpoImageFile: """ Transform the instance of JpegImageFile into an instance of MpoImageFile. @@ -116,8 +170,9 @@ def adopt(jpeg_instance, mpheader=None): double call to _open. """ jpeg_instance.__class__ = MpoImageFile - jpeg_instance._after_jpeg_open(mpheader) - return jpeg_instance + mpo_instance = cast(MpoImageFile, jpeg_instance) + mpo_instance._after_jpeg_open(mpheader) + return mpo_instance # --------------------------------------------------------------------- @@ -128,6 +183,7 @@ def adopt(jpeg_instance, mpheader=None): # Image.register_open(MpoImageFile.format, # JpegImagePlugin.jpeg_factory, _accept) Image.register_save(MpoImageFile.format, _save) +Image.register_save_all(MpoImageFile.format, _save_all) Image.register_extension(MpoImageFile.format, ".mpo") diff --git a/dependencies/OSX/PIL/MspImagePlugin.py b/dependencies/OSX/PIL/MspImagePlugin.py index 9dd6e9f..ef6ae87 100644 --- a/dependencies/OSX/PIL/MspImagePlugin.py +++ b/dependencies/OSX/PIL/MspImagePlugin.py @@ -21,13 +21,14 @@ # Figure 205. Windows Paint Version 1: "DanM" Format # Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03 # -# See also: http://www.fileformat.info/format/mspaint/egff.htm +# See also: https://www.fileformat.info/format/mspaint/egff.htm +from __future__ import annotations import io import struct +from typing import IO from . import Image, ImageFile -from ._binary import i8 from ._binary import i16le as i16 from ._binary import o16le as o16 @@ -35,7 +36,7 @@ # read MSP files -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] in [b"DanM", b"LinS"] @@ -45,36 +46,38 @@ def _accept(prefix): class MspImageFile(ImageFile.ImageFile): - format = "MSP" format_description = "Windows Paint" - def _open(self): - + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an MSP file") + msg = "not an MSP file" + raise SyntaxError(msg) # Header checksum checksum = 0 for i in range(0, 32, 2): - checksum = checksum ^ i16(s[i : i + 2]) + checksum = checksum ^ i16(s, i) if checksum != 0: - raise SyntaxError("bad MSP checksum") + msg = "bad MSP checksum" + raise SyntaxError(msg) - self.mode = "1" - self._size = i16(s[4:]), i16(s[6:]) + self._mode = "1" + self._size = i16(s, 4), i16(s, 6) if s[:4] == b"DanM": - self.tile = [("raw", (0, 0) + self.size, 32, ("1", 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")] else: - self.tile = [("MSP", (0, 0) + self.size, 32, None)] + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)] class MspDecoder(ImageFile.PyDecoder): # The algo for the MSP decoder is from - # http://www.fileformat.info/format/mspaint/egff.htm + # https://www.fileformat.info/format/mspaint/egff.htm # cc-by-attribution -- That page references is taken from the # Encyclopedia of Graphics File Formats and is licensed by # O'Reilly under the Creative Common/Attribution license @@ -109,7 +112,8 @@ class MspDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None img = io.BytesIO() blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8)) @@ -119,7 +123,8 @@ def decode(self, buffer): f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2) ) except struct.error as e: - raise OSError("Truncated MSP file in row map") from e + msg = "Truncated MSP file in row map" + raise OSError(msg) from e for x, rowlen in enumerate(rowmap): try: @@ -128,12 +133,11 @@ def decode(self, buffer): continue row = self.fd.read(rowlen) if len(row) != rowlen: - raise OSError( - "Truncated MSP file, expected %d bytes on row %s", (rowlen, x) - ) + msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}" + raise OSError(msg) idx = 0 while idx < rowlen: - runtype = i8(row[idx]) + runtype = row[idx] idx += 1 if runtype == 0: (runcount, runval) = struct.unpack_from("Bc", row, idx) @@ -145,11 +149,12 @@ def decode(self, buffer): idx += runcount except struct.error as e: - raise OSError(f"Corrupted MSP file in row {x}") from e + msg = f"Corrupted MSP file in row {x}" + raise OSError(msg) from e - self.set_as_raw(img.getvalue(), ("1", 0, 1)) + self.set_as_raw(img.getvalue(), "1") - return 0, 0 + return -1, 0 Image.register_decoder("MSP", MspDecoder) @@ -159,10 +164,10 @@ def decode(self, buffer): # write MSP files (uncompressed only) -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as MSP") + msg = f"cannot write mode {im.mode} as MSP" + raise OSError(msg) # create MSP header header = [0] * 16 @@ -183,7 +188,7 @@ def _save(im, fp, filename): fp.write(o16(h)) # image body - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 32, ("1", 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")]) # diff --git a/dependencies/OSX/PIL/PSDraw.py b/dependencies/OSX/PIL/PSDraw.py index c1bd933..02939d2 100644 --- a/dependencies/OSX/PIL/PSDraw.py +++ b/dependencies/OSX/PIL/PSDraw.py @@ -14,8 +14,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys +from typing import IO, TYPE_CHECKING from . import EpsImagePlugin @@ -26,96 +28,93 @@ class PSDraw: """ Sets up printing to the given file. If ``fp`` is omitted, - :py:data:`sys.stdout` is assumed. + ``sys.stdout.buffer`` is assumed. """ - def __init__(self, fp=None): + def __init__(self, fp: IO[bytes] | None = None) -> None: if not fp: - fp = sys.stdout + fp = sys.stdout.buffer self.fp = fp - def _fp_write(self, to_write): - if self.fp == sys.stdout: - self.fp.write(to_write) - else: - self.fp.write(bytes(to_write, "UTF-8")) - - def begin_document(self, id=None): + def begin_document(self, id: str | None = None) -> None: """Set up printing of a document. (Write PostScript DSC header.)""" # FIXME: incomplete - self._fp_write( - "%!PS-Adobe-3.0\n" - "save\n" - "/showpage { } def\n" - "%%EndComments\n" - "%%BeginDocument\n" + self.fp.write( + b"%!PS-Adobe-3.0\n" + b"save\n" + b"/showpage { } def\n" + b"%%EndComments\n" + b"%%BeginDocument\n" ) - # self._fp_write(ERROR_PS) # debugging! - self._fp_write(EDROFF_PS) - self._fp_write(VDI_PS) - self._fp_write("%%EndProlog\n") - self.isofont = {} + # self.fp.write(ERROR_PS) # debugging! + self.fp.write(EDROFF_PS) + self.fp.write(VDI_PS) + self.fp.write(b"%%EndProlog\n") + self.isofont: dict[bytes, int] = {} - def end_document(self): + def end_document(self) -> None: """Ends printing. (Write PostScript DSC footer.)""" - self._fp_write("%%EndDocument\nrestore showpage\n%%End\n") + self.fp.write(b"%%EndDocument\nrestore showpage\n%%End\n") if hasattr(self.fp, "flush"): self.fp.flush() - def setfont(self, font, size): + def setfont(self, font: str, size: int) -> None: """ Selects which font to use. :param font: A PostScript font name :param size: Size in points. """ - if font not in self.isofont: + font_bytes = bytes(font, "UTF-8") + if font_bytes not in self.isofont: # reencode font - self._fp_write(f"/PSDraw-{font} ISOLatin1Encoding /{font} E\n") - self.isofont[font] = 1 + self.fp.write( + b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font_bytes, font_bytes) + ) + self.isofont[font_bytes] = 1 # rough - self._fp_write(f"/F0 {size} /PSDraw-{font} F\n") + self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font_bytes)) - def line(self, xy0, xy1): + def line(self, xy0: tuple[int, int], xy1: tuple[int, int]) -> None: """ Draws a line between the two points. Coordinates are given in PostScript point coordinates (72 points per inch, (0, 0) is the lower left corner of the page). """ - self._fp_write("%d %d %d %d Vl\n" % (*xy0, *xy1)) + self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1)) - def rectangle(self, box): + def rectangle(self, box: tuple[int, int, int, int]) -> None: """ Draws a rectangle. - :param box: A 4-tuple of integers whose order and function is currently - undocumented. - - Hint: the tuple is passed into this format string: - - .. code-block:: python - - %d %d M %d %d 0 Vr\n + :param box: A tuple of four integers, specifying left, bottom, width and + height. """ - self._fp_write("%d %d M %d %d 0 Vr\n" % box) + self.fp.write(b"%d %d M 0 %d %d Vr\n" % box) - def text(self, xy, text): + def text(self, xy: tuple[int, int], text: str) -> None: """ Draws text at the given position. You must use :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. """ - text = "\\(".join(text.split("(")) - text = "\\)".join(text.split(")")) - self._fp_write(f"{xy[0]} {xy[1]} M ({text}) S\n") + text_bytes = bytes(text, "UTF-8") + text_bytes = b"\\(".join(text_bytes.split(b"(")) + text_bytes = b"\\)".join(text_bytes.split(b")")) + self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) + + if TYPE_CHECKING: + from . import Image - def image(self, box, im, dpi=None): + def image( + self, box: tuple[int, int, int, int], im: Image.Image, dpi: int | None = None + ) -> None: """Draw a PIL image, centered in the given box.""" # default resolution depends on mode if not dpi: if im.mode == "1": dpi = 200 # fax else: - dpi = 100 # greyscale + dpi = 100 # grayscale # image size (on paper) x = im.size[0] * 72 / dpi y = im.size[1] * 72 / dpi @@ -130,14 +129,14 @@ def image(self, box, im, dpi=None): y = ymax dx = (xmax - x) / 2 + box[0] dy = (ymax - y) / 2 + box[1] - self._fp_write(f"gsave\n{dx:f} {dy:f} translate\n") + self.fp.write(b"gsave\n%f %f translate\n" % (dx, dy)) if (x, y) != im.size: # EpsImagePlugin._save prints the image at (0,0,xsize,ysize) sx = x / im.size[0] sy = y / im.size[1] - self._fp_write(f"{sx:f} {sy:f} scale\n") - EpsImagePlugin._save(im, self.fp, None, 0) - self._fp_write("\ngrestore\n") + self.fp.write(b"%f %f scale\n" % (sx, sy)) + EpsImagePlugin._save(im, self.fp, "", 0) + self.fp.write(b"\ngrestore\n") # -------------------------------------------------------------------- @@ -153,7 +152,7 @@ def image(self, box, im, dpi=None): # -EDROFF_PS = """\ +EDROFF_PS = b"""\ /S { show } bind def /P { moveto show } bind def /M { moveto } bind def @@ -182,16 +181,16 @@ def image(self, box, im, dpi=None): # Copyright (c) Fredrik Lundh 1994. # -VDI_PS = """\ +VDI_PS = b"""\ /Vm { moveto } bind def /Va { newpath arcn stroke } bind def /Vl { moveto lineto stroke } bind def /Vc { newpath 0 360 arc closepath } bind def /Vr { exch dup 0 rlineto - exch dup neg 0 exch rlineto + exch dup 0 exch rlineto exch neg 0 rlineto - 0 exch rlineto - 100 div setgray fill 0 setgray } bind def + 0 exch neg rlineto + setgray fill } bind def /Tm matrix def /Ve { Tm currentmatrix pop translate scale newpath 0 0 .5 0 360 arc closepath @@ -207,7 +206,7 @@ def image(self, box, im, dpi=None): # 89-11-21 fl: created (pslist 1.10) # -ERROR_PS = """\ +ERROR_PS = b"""\ /landscape false def /errorBUF 200 string def /errorNL { currentpoint 10 sub exch pop 72 exch moveto } def diff --git a/dependencies/OSX/PIL/PaletteFile.py b/dependencies/OSX/PIL/PaletteFile.py index 6ccaa1f..81652e5 100644 --- a/dependencies/OSX/PIL/PaletteFile.py +++ b/dependencies/OSX/PIL/PaletteFile.py @@ -12,6 +12,9 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from ._binary import o8 @@ -21,20 +24,19 @@ class PaletteFile: rawmode = "RGB" - def __init__(self, fp): - - self.palette = [(i, i, i) for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] while True: - s = fp.readline() if not s: break - if s[0:1] == b"#": + if s[:1] == b"#": continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = [int(x) for x in s.split()] try: @@ -44,10 +46,9 @@ def __init__(self, fp): g = b = r if 0 <= i <= 255: - self.palette[i] = o8(r) + o8(g) + o8(b) - - self.palette = b"".join(self.palette) + palette[i] = o8(r) + o8(g) + o8(b) - def getpalette(self): + self.palette = b"".join(palette) + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/dependencies/OSX/PIL/PalmImagePlugin.py b/dependencies/OSX/PIL/PalmImagePlugin.py index 700f10e..b332453 100644 --- a/dependencies/OSX/PIL/PalmImagePlugin.py +++ b/dependencies/OSX/PIL/PalmImagePlugin.py @@ -6,6 +6,9 @@ ## # Image plugin for Palm pixmap images (output only). ## +from __future__ import annotations + +from typing import IO from . import Image, ImageFile from ._binary import o8 @@ -81,10 +84,10 @@ # so build a prototype image to be used for palette resampling -def build_prototype_image(): +def build_prototype_image() -> Image.Image: image = Image.new("L", (1, len(_Palm8BitColormapValues))) image.putdata(list(range(len(_Palm8BitColormapValues)))) - palettedata = () + palettedata: tuple[int, ...] = () for colormapValue in _Palm8BitColormapValues: palettedata += colormapValue palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues)) @@ -111,10 +114,8 @@ def build_prototype_image(): # (Internal) Image save plugin for the Palm format. -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, # unless the "info" dict has a "custom-colormap" field @@ -126,35 +127,36 @@ def _save(im, fp, filename): if im.encoderinfo.get("bpp") in (1, 2, 4): # this is 8-bit grayscale, so we shift it to get the high-order bits, # and invert it because - # Palm does greyscale from white (0) to black (1) + # Palm does grayscale from white (0) to black (1) bpp = im.encoderinfo["bpp"] - im = im.point( - lambda x, shift=8 - bpp, maxval=(1 << bpp) - 1: maxval - (x >> shift) - ) + maxval = (1 << bpp) - 1 + shift = 8 - bpp + im = im.point(lambda x: maxval - (x >> shift)) elif im.info.get("bpp") in (1, 2, 4): # here we assume that even though the inherent mode is 8-bit grayscale, # only the lower bpp bits are significant. # We invert them to match the Palm. bpp = im.info["bpp"] - im = im.point(lambda x, maxval=(1 << bpp) - 1: maxval - (x & maxval)) + maxval = (1 << bpp) - 1 + im = im.point(lambda x: maxval - (x & maxval)) else: - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # we ignore the palette here - im.mode = "P" - rawmode = "P;" + str(bpp) + im._mode = "P" + rawmode = f"P;{bpp}" version = 1 elif im.mode == "1": - # monochrome -- write it inverted, as is the Palm standard rawmode = "1;I" bpp = 1 version = 0 else: - - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # # make sure image data is available @@ -171,6 +173,7 @@ def _save(im, fp, filename): flags = 0 if im.mode == "P" and "custom-colormap" in im.info: + assert im.palette is not None flags = flags & _FLAGS["custom-colormap"] colormapsize = 4 * 256 + 2 colormapmode = im.palette.mode @@ -211,7 +214,9 @@ def _save(im, fp, filename): ) # now convert data to raw form - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))] + ) if hasattr(fp, "flush"): fp.flush() diff --git a/dependencies/OSX/PIL/PcdImagePlugin.py b/dependencies/OSX/PIL/PcdImagePlugin.py index 625f556..ac40383 100644 --- a/dependencies/OSX/PIL/PcdImagePlugin.py +++ b/dependencies/OSX/PIL/PcdImagePlugin.py @@ -13,10 +13,9 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile -from ._binary import i8 ## # Image plugin for PhotoCD images. This plugin only reads the 768x512 @@ -25,31 +24,32 @@ class PcdImageFile(ImageFile.ImageFile): - format = "PCD" format_description = "Kodak PhotoCD" - def _open(self): - + def _open(self) -> None: # rough + assert self.fp is not None + self.fp.seek(2048) s = self.fp.read(2048) if s[:4] != b"PCD_": - raise SyntaxError("not a PCD file") + msg = "not a PCD file" + raise SyntaxError(msg) - orientation = i8(s[1538]) & 3 + orientation = s[1538] & 3 self.tile_post_rotate = None if orientation == 1: self.tile_post_rotate = 90 elif orientation == 3: self.tile_post_rotate = -90 - self.mode = "RGB" + self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! - self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)] + self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048)] - def load_end(self): + def load_end(self) -> None: if self.tile_post_rotate: # Handle rotated PCDs self.im = self.im.rotate(self.tile_post_rotate) diff --git a/dependencies/OSX/PIL/PcfFontFile.py b/dependencies/OSX/PIL/PcfFontFile.py index 6a4eb22..0d1968b 100644 --- a/dependencies/OSX/PIL/PcfFontFile.py +++ b/dependencies/OSX/PIL/PcfFontFile.py @@ -15,8 +15,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io +from typing import BinaryIO, Callable from . import FontFile, Image from ._binary import i8 @@ -40,7 +42,7 @@ PCF_GLYPH_NAMES = 1 << 7 PCF_BDF_ACCELERATORS = 1 << 8 -BYTES_PER_ROW = [ +BYTES_PER_ROW: list[Callable[[int], int]] = [ lambda bits: ((bits + 7) >> 3), lambda bits: ((bits + 15) >> 3) & ~1, lambda bits: ((bits + 31) >> 3) & ~3, @@ -48,7 +50,7 @@ ] -def sz(s, o): +def sz(s: bytes, o: int) -> bytes: return s[o : s.index(b"\0", o)] @@ -57,13 +59,13 @@ class PcfFontFile(FontFile.FontFile): name = "name" - def __init__(self, fp, charset_encoding="iso8859-1"): - + def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"): self.charset_encoding = charset_encoding magic = l32(fp.read(4)) if magic != PCF_MAGIC: - raise SyntaxError("not a PCF file") + msg = "not a PCF file" + raise SyntaxError(msg) super().__init__() @@ -84,15 +86,28 @@ def __init__(self, fp, charset_encoding="iso8859-1"): # # create glyph structure - for ch in range(256): - ix = encoding[ch] + for ch, ix in enumerate(encoding): if ix is not None: - x, y, l, r, w, a, d, f = metrics[ix] - glyph = (w, 0), (l, d - y, x + l, d), (0, 0, x, y), bitmaps[ix] - self.glyph[ch] = glyph - - def _getformat(self, tag): - + ( + xsize, + ysize, + left, + right, + width, + ascent, + descent, + attributes, + ) = metrics[ix] + self.glyph[ch] = ( + (width, 0), + (left, descent - ysize, xsize + left, descent), + (0, 0, xsize, ysize), + bitmaps[ix], + ) + + def _getformat( + self, tag: int + ) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]: format, size, offset = self.toc[tag] fp = self.fp @@ -107,8 +122,7 @@ def _getformat(self, tag): return fp, format, i16, i32 - def _load_properties(self): - + def _load_properties(self) -> dict[bytes, bytes | int]: # # font properties @@ -119,35 +133,30 @@ def _load_properties(self): nprops = i32(fp.read(4)) # read property description - p = [] - for i in range(nprops): - p.append((i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4)))) + p = [(i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4))) for _ in range(nprops)] + if nprops & 3: fp.seek(4 - (nprops & 3), io.SEEK_CUR) # pad data = fp.read(i32(fp.read(4))) for k, s, v in p: - k = sz(data, k) - if s: - v = sz(data, v) - properties[k] = v + property_value: bytes | int = sz(data, v) if s else v + properties[sz(data, k)] = property_value return properties - def _load_metrics(self): - + def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]: # # font metrics - metrics = [] + metrics: list[tuple[int, int, int, int, int, int, int, int]] = [] fp, format, i16, i32 = self._getformat(PCF_METRICS) append = metrics.append if (format & 0xFF00) == 0x100: - # "compressed" metrics for i in range(i16(fp.read(2))): left = i8(fp.read(1)) - 128 @@ -160,7 +169,6 @@ def _load_metrics(self): append((xsize, ysize, left, right, width, ascent, descent, 0)) else: - # "jumbo" metrics for i in range(i32(fp.read(4))): left = i16(fp.read(2)) @@ -175,33 +183,29 @@ def _load_metrics(self): return metrics - def _load_bitmaps(self, metrics): - + def _load_bitmaps( + self, metrics: list[tuple[int, int, int, int, int, int, int, int]] + ) -> list[Image.Image]: # # bitmap data - bitmaps = [] - fp, format, i16, i32 = self._getformat(PCF_BITMAPS) nbitmaps = i32(fp.read(4)) if nbitmaps != len(metrics): - raise OSError("Wrong number of bitmaps") + msg = "Wrong number of bitmaps" + raise OSError(msg) - offsets = [] - for i in range(nbitmaps): - offsets.append(i32(fp.read(4))) + offsets = [i32(fp.read(4)) for _ in range(nbitmaps)] - bitmapSizes = [] - for i in range(4): - bitmapSizes.append(i32(fp.read(4))) + bitmap_sizes = [i32(fp.read(4)) for _ in range(4)] # byteorder = format & 4 # non-zero => MSB bitorder = format & 8 # non-zero => MSB padindex = format & 3 - bitmapsize = bitmapSizes[padindex] + bitmapsize = bitmap_sizes[padindex] offsets.append(bitmapsize) data = fp.read(bitmapsize) @@ -211,36 +215,38 @@ def _load_bitmaps(self, metrics): if bitorder: mode = "1" + bitmaps = [] for i in range(nbitmaps): - x, y, l, r, w, a, d, f = metrics[i] - b, e = offsets[i], offsets[i + 1] - bitmaps.append(Image.frombytes("1", (x, y), data[b:e], "raw", mode, pad(x))) + xsize, ysize = metrics[i][:2] + b, e = offsets[i : i + 2] + bitmaps.append( + Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) + ) return bitmaps - def _load_encoding(self): - - # map character code to bitmap index - encoding = [None] * 256 - + def _load_encoding(self) -> list[int | None]: fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS) - firstCol, lastCol = i16(fp.read(2)), i16(fp.read(2)) - firstRow, lastRow = i16(fp.read(2)), i16(fp.read(2)) + first_col, last_col = i16(fp.read(2)), i16(fp.read(2)) + first_row, last_row = i16(fp.read(2)), i16(fp.read(2)) i16(fp.read(2)) # default - nencoding = (lastCol - firstCol + 1) * (lastRow - firstRow + 1) + nencoding = (last_col - first_col + 1) * (last_row - first_row + 1) + + # map character code to bitmap index + encoding: list[int | None] = [None] * min(256, nencoding) - encodingOffsets = [i16(fp.read(2)) for _ in range(nencoding)] + encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)] - for i in range(firstCol, len(encoding)): + for i in range(first_col, len(encoding)): try: - encodingOffset = encodingOffsets[ + encoding_offset = encoding_offsets[ ord(bytearray([i]).decode(self.charset_encoding)) ] - if encodingOffset != 0xFFFF: - encoding[i] = encodingOffset + if encoding_offset != 0xFFFF: + encoding[i] = encoding_offset except UnicodeDecodeError: # character is not supported in selected encoding pass diff --git a/dependencies/OSX/PIL/PcxImagePlugin.py b/dependencies/OSX/PIL/PcxImagePlugin.py index 767f994..32436ce 100644 --- a/dependencies/OSX/PIL/PcxImagePlugin.py +++ b/dependencies/OSX/PIL/PcxImagePlugin.py @@ -24,12 +24,13 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import logging +from typing import IO from . import Image, ImageFile, ImagePalette -from ._binary import i8 from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 @@ -37,8 +38,8 @@ logger = logging.getLogger(__name__) -def _accept(prefix): - return i8(prefix[0]) == 10 and i8(prefix[1]) in [0, 2, 3, 5] +def _accept(prefix: bytes) -> bool: + return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5] ## @@ -46,34 +47,36 @@ def _accept(prefix): class PcxImageFile(ImageFile.ImageFile): - format = "PCX" format_description = "Paintbrush" - def _open(self): - + def _open(self) -> None: # header + assert self.fp is not None + s = self.fp.read(128) if not _accept(s): - raise SyntaxError("not a PCX file") + msg = "not a PCX file" + raise SyntaxError(msg) # image bbox = i16(s, 4), i16(s, 6), i16(s, 8) + 1, i16(s, 10) + 1 if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: - raise SyntaxError("bad PCX image size") + msg = "bad PCX image size" + raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) # format - version = i8(s[1]) - bits = i8(s[3]) - planes = i8(s[65]) - stride = i16(s, 66) + version = s[1] + bits = s[3] + planes = s[65] + provided_stride = i16(s, 66) logger.debug( "PCX version %s, bits %s, planes %s, stride %s", version, bits, planes, - stride, + provided_stride, ) self.info["dpi"] = i16(s, 12), i16(s, 14) @@ -83,7 +86,7 @@ def _open(self): elif bits == 1 and planes in (2, 4): mode = "P" - rawmode = "P;%dL" % planes + rawmode = f"P;{planes}L" self.palette = ImagePalette.raw("RGB", s[16:64]) elif version == 5 and bits == 8 and planes == 1: @@ -91,8 +94,8 @@ def _open(self): # FIXME: hey, this doesn't work with the incremental loader !!! self.fp.seek(-769, io.SEEK_END) s = self.fp.read(769) - if len(s) == 769 and i8(s[0]) == 12: - # check if the palette is linear greyscale + if len(s) == 769 and s[0] == 12: + # check if the palette is linear grayscale for i in range(256): if s[i * 3 + 1 : i * 3 + 4] != o8(i) * 3: mode = rawmode = "P" @@ -106,15 +109,28 @@ def _open(self): rawmode = "RGB;L" else: - raise OSError("unknown PCX mode") + msg = "unknown PCX mode" + raise OSError(msg) - self.mode = mode + self._mode = mode self._size = bbox[2] - bbox[0], bbox[3] - bbox[1] + # Don't trust the passed in stride. + # Calculate the approximate position for ourselves. + # CVE-2020-35653 + stride = (self._size[0] * bits + 7) // 8 + + # While the specification states that this must be even, + # not all images follow this + if provided_stride != stride: + stride += stride % 2 + bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [("pcx", bbox, self.fp.tell(), (rawmode, planes * stride))] + self.tile = [ + ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) + ] # -------------------------------------------------------------------- @@ -130,12 +146,12 @@ def _open(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as PCX") from e + msg = f"Cannot save {im.mode} images as PCX" + raise ValueError(msg) from e # bytes per plane stride = (im.size[0] * bits + 7) // 8 @@ -184,14 +200,18 @@ def _save(im, fp, filename): assert fp.tell() == 128 - ImageFile._save(im, fp, [("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))]) + ImageFile._save( + im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))] + ) if im.mode == "P": # colour palette fp.write(o8(12)) - fp.write(im.im.getpalette("RGB", "RGB")) # 768 bytes + palette = im.im.getpalette("RGB", "RGB") + palette += b"\x00" * (768 - len(palette)) + fp.write(palette) # 768 bytes elif im.mode == "L": - # greyscale palette + # grayscale palette fp.write(o8(12)) for i in range(256): fp.write(o8(i) * 3) diff --git a/dependencies/OSX/PIL/PdfImagePlugin.py b/dependencies/OSX/PIL/PdfImagePlugin.py index 36c8fb8..e9c20dd 100644 --- a/dependencies/OSX/PIL/PdfImagePlugin.py +++ b/dependencies/OSX/PIL/PdfImagePlugin.py @@ -19,12 +19,15 @@ ## # Image plugin for PDF images (output only). ## +from __future__ import annotations import io +import math import os import time +from typing import IO, Any -from . import Image, ImageFile, ImageSequence, PdfParser, __version__ +from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # # -------------------------------------------------------------------- @@ -37,7 +40,7 @@ # 5. page contents -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) @@ -45,19 +48,160 @@ def _save_all(im, fp, filename): # (Internal) Image save plugin for the PDF format. -def _save(im, fp, filename, save_all=False): +def _write_image( + im: Image.Image, + filename: str | bytes, + existing_pdf: PdfParser.PdfParser, + image_refs: list[PdfParser.IndirectReference], +) -> tuple[PdfParser.IndirectReference, str]: + # FIXME: Should replace ASCIIHexDecode with RunLengthDecode + # (packbits) or LZWDecode (tiff/lzw compression). Note that + # PDF 1.2 also supports Flatedecode (zip compression). + + params = None + decode = None + + # + # Get image characteristics + + width, height = im.size + + dict_obj: dict[str, Any] = {"BitsPerComponent": 8} + if im.mode == "1": + if features.check("libtiff"): + decode_filter = "CCITTFaxDecode" + dict_obj["BitsPerComponent"] = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "L": + decode_filter = "DCTDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "LA": + decode_filter = "JPXDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + procset = "ImageB" # grayscale + dict_obj["SMaskInData"] = 1 + elif im.mode == "P": + decode_filter = "ASCIIHexDecode" + palette = im.getpalette() + assert palette is not None + dict_obj["ColorSpace"] = [ + PdfParser.PdfName("Indexed"), + PdfParser.PdfName("DeviceRGB"), + len(palette) // 3 - 1, + PdfParser.PdfBinary(palette), + ] + procset = "ImageI" # indexed color + + if "transparency" in im.info: + smask = im.convert("LA").getchannel("A") + smask.encoderinfo = {} + + image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] + dict_obj["SMask"] = image_ref + elif im.mode == "RGB": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") + procset = "ImageC" # color images + elif im.mode == "RGBA": + decode_filter = "JPXDecode" + procset = "ImageC" # color images + dict_obj["SMaskInData"] = 1 + elif im.mode == "CMYK": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") + procset = "ImageC" # color images + decode = [1, 0, 1, 0, 1, 0, 1, 0] + else: + msg = f"cannot save mode {im.mode}" + raise ValueError(msg) + + # + # image + + op = io.BytesIO() + + if decode_filter == "ASCIIHexDecode": + ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)]) + elif decode_filter == "CCITTFaxDecode": + im.save( + op, + "TIFF", + compression="group4", + # use a single strip + strip_size=math.ceil(width / 8) * height, + ) + elif decode_filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif decode_filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + else: + msg = f"unsupported PDF filter ({decode_filter})" + raise ValueError(msg) + + stream = op.getvalue() + filter: PdfParser.PdfArray | PdfParser.PdfName + if decode_filter == "CCITTFaxDecode": + stream = stream[8:] + filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)]) + else: + filter = PdfParser.PdfName(decode_filter) + + image_ref = image_refs.pop(0) + existing_pdf.write_obj( + image_ref, + stream=stream, + Type=PdfParser.PdfName("XObject"), + Subtype=PdfParser.PdfName("Image"), + Width=width, # * 72.0 / x_resolution, + Height=height, # * 72.0 / y_resolution, + Filter=filter, + Decode=decode, + DecodeParms=params, + **dict_obj, + ) + + return image_ref, procset + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: is_appending = im.encoderinfo.get("append", False) + filename_str = filename.decode() if isinstance(filename, bytes) else filename if is_appending: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="r+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") else: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="w+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") - resolution = im.encoderinfo.get("resolution", 72.0) + dpi = im.encoderinfo.get("dpi") + if dpi: + x_resolution = dpi[0] + y_resolution = dpi[1] + else: + x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) info = { - "title": None - if is_appending - else os.path.splitext(os.path.basename(filename))[0], + "title": ( + None if is_appending else os.path.splitext(os.path.basename(filename))[0] + ), "author": None, "subject": None, "keywords": None, @@ -87,22 +231,20 @@ def _save(im, fp, filename, save_all=False): for append_im in append_images: append_im.encoderinfo = im.encoderinfo.copy() ims.append(append_im) - numberOfPages = 0 + number_of_pages = 0 image_refs = [] page_refs = [] contents_refs = [] for im in ims: - im_numberOfPages = 1 + im_number_of_pages = 1 if save_all: - try: - im_numberOfPages = im.n_frames - except AttributeError: - # Image format does not have n_frames. - # It is a single frame image - pass - numberOfPages += im_numberOfPages - for i in range(im_numberOfPages): + im_number_of_pages = getattr(im, "n_frames", 1) + number_of_pages += im_number_of_pages + for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) + if im.mode == "P" and "transparency" in im.info: + image_refs.append(existing_pdf.next_object_id(0)) + page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) existing_pdf.pages.append(page_refs[-1]) @@ -111,120 +253,43 @@ def _save(im, fp, filename, save_all=False): # catalog and list of pages existing_pdf.write_catalog() - pageNumber = 0 - for imSequence in ims: - im_pages = ImageSequence.Iterator(imSequence) if save_all else [imSequence] + page_number = 0 + for im_sequence in ims: + im_pages: ImageSequence.Iterator | list[Image.Image] = ( + ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] + ) for im in im_pages: - # FIXME: Should replace ASCIIHexDecode with RunLengthDecode - # (packbits) or LZWDecode (tiff/lzw compression). Note that - # PDF 1.2 also supports Flatedecode (zip compression). - - bits = 8 - params = None - decode = None - - if im.mode == "1": - filter = "ASCIIHexDecode" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - bits = 1 - elif im.mode == "L": - filter = "DCTDecode" - # params = f"<< /Predictor 15 /Columns {width-2} >>" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "P": - filter = "ASCIIHexDecode" - palette = im.im.getpalette("RGB") - colorspace = [ - PdfParser.PdfName("Indexed"), - PdfParser.PdfName("DeviceRGB"), - 255, - PdfParser.PdfBinary(palette), - ] - procset = "ImageI" # indexed color - elif im.mode == "RGB": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceRGB") - procset = "ImageC" # color images - elif im.mode == "CMYK": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceCMYK") - procset = "ImageC" # color images - decode = [1, 0, 1, 0, 1, 0, 1, 0] - else: - raise ValueError(f"cannot save mode {im.mode}") - - # - # image - - op = io.BytesIO() - - if filter == "ASCIIHexDecode": - if bits == 1: - # FIXME: the hex encoder doesn't support packed 1-bit - # images; do things the hard way... - data = im.tobytes("raw", "1") - im = Image.new("L", im.size) - im.putdata(data) - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) - elif filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) - elif filter == "FlateDecode": - ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) - elif filter == "RunLengthDecode": - ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) - else: - raise ValueError(f"unsupported PDF filter ({filter})") - - # - # Get image characteristics - - width, height = im.size - - existing_pdf.write_obj( - image_refs[pageNumber], - stream=op.getvalue(), - Type=PdfParser.PdfName("XObject"), - Subtype=PdfParser.PdfName("Image"), - Width=width, # * 72.0 / resolution, - Height=height, # * 72.0 / resolution, - Filter=PdfParser.PdfName(filter), - BitsPerComponent=bits, - Decode=decode, - DecodeParams=params, - ColorSpace=colorspace, - ) + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) # # page existing_pdf.write_page( - page_refs[pageNumber], + page_refs[page_number], Resources=PdfParser.PdfDict( ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], - XObject=PdfParser.PdfDict(image=image_refs[pageNumber]), + XObject=PdfParser.PdfDict(image=image_ref), ), MediaBox=[ 0, 0, - int(width * 72.0 / resolution), - int(height * 72.0 / resolution), + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ], - Contents=contents_refs[pageNumber], + Contents=contents_refs[page_number], ) # # page contents - page_contents = b"q %d 0 0 %d 0 0 cm /image Do Q\n" % ( - int(width * 72.0 / resolution), - int(height * 72.0 / resolution), + page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ) - existing_pdf.write_obj(contents_refs[pageNumber], stream=page_contents) + existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) - pageNumber += 1 + page_number += 1 # # trailer diff --git a/dependencies/OSX/PIL/PdfParser.py b/dependencies/OSX/PIL/PdfParser.py index 975905f..7cb2d24 100644 --- a/dependencies/OSX/PIL/PdfParser.py +++ b/dependencies/OSX/PIL/PdfParser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import calendar import codecs import collections @@ -6,11 +8,12 @@ import re import time import zlib +from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set # on page 656 -def encode_text(s): +def encode_text(s: str) -> bytes: return codecs.BOM_UTF16_BE + s.encode("utf_16_be") @@ -59,7 +62,7 @@ def encode_text(s): } -def decode_text(b): +def decode_text(b: bytes) -> str: if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") else: @@ -73,47 +76,53 @@ class PdfFormatError(RuntimeError): pass -def check_format_condition(condition, error_message): +def check_format_condition(condition: bool, error_message: str) -> None: if not condition: raise PdfFormatError(error_message) -class IndirectReference( - collections.namedtuple("IndirectReferenceTuple", ["object_id", "generation"]) -): - def __str__(self): - return "%s %s R" % self +class IndirectReferenceTuple(NamedTuple): + object_id: int + generation: int + - def __bytes__(self): +class IndirectReference(IndirectReferenceTuple): + def __str__(self) -> str: + return f"{self.object_id} {self.generation} R" + + def __bytes__(self) -> bytes: return self.__str__().encode("us-ascii") - def __eq__(self, other): - return ( - other.__class__ is self.__class__ - and other.object_id == self.object_id - and other.generation == self.generation - ) + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, IndirectReference) + return other.object_id == self.object_id and other.generation == self.generation - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.object_id, self.generation)) class IndirectObjectDef(IndirectReference): - def __str__(self): - return "%s %s obj" % self + def __str__(self) -> str: + return f"{self.object_id} {self.generation} obj" class XrefTable: - def __init__(self): - self.existing_entries = {} # object ID => (offset, generation) - self.new_entries = {} # object ID => (offset, generation) + def __init__(self) -> None: + self.existing_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.new_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: @@ -121,13 +130,13 @@ def __setitem__(self, key, value): if key in self.deleted_entries: del self.deleted_entries[key] - def __getitem__(self, key): + def __getitem__(self, key: int) -> tuple[int, int]: try: return self.new_entries[key] except KeyError: return self.existing_entries[key] - def __delitem__(self, key): + def __delitem__(self, key: int) -> None: if key in self.new_entries: generation = self.new_entries[key][1] + 1 del self.new_entries[key] @@ -138,33 +147,32 @@ def __delitem__(self, key): elif key in self.deleted_entries: generation = self.deleted_entries[key] else: - raise IndexError( - "object ID " + str(key) + " cannot be deleted because it doesn't exist" - ) + msg = f"object ID {key} cannot be deleted because it doesn't exist" + raise IndexError(msg) - def __contains__(self, key): + def __contains__(self, key: int) -> bool: return key in self.existing_entries or key in self.new_entries - def __len__(self): + def __len__(self) -> int: return len( set(self.existing_entries.keys()) | set(self.new_entries.keys()) | set(self.deleted_entries.keys()) ) - def keys(self): + def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) - def write(self, f): + def write(self, f: IO[bytes]) -> int: keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) deleted_keys = sorted(set(self.deleted_entries.keys())) startxref = f.tell() f.write(b"xref\n") while keys: # find a contiguous sequence of object IDs - prev = None + prev: int | None = None for index, key in enumerate(keys): if prev is None or prev + 1 == key: prev = key @@ -174,7 +182,7 @@ def write(self, f): break else: contiguous_keys = keys - keys = None + keys = [] f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) for object_id in contiguous_keys: if object_id in self.new_entries: @@ -198,7 +206,9 @@ def write(self, f): class PdfName: - def __init__(self, name): + name: bytes + + def __init__(self, name: PdfName | bytes | str) -> None: if isinstance(name, PdfName): self.name = name.name elif isinstance(name, bytes): @@ -206,27 +216,27 @@ def __init__(self, name): else: self.name = name.encode("us-ascii") - def name_as_str(self): + def name_as_str(self) -> str: return self.name.decode("us-ascii") - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return ( isinstance(other, PdfName) and other.name == self.name ) or other == self.name - def __hash__(self): + def __hash__(self) -> int: return hash(self.name) - def __repr__(self): - return f"PdfName({repr(self.name)})" + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.name)})" @classmethod - def from_pdf_stream(cls, data): + def from_pdf_stream(cls, data: bytes) -> PdfName: return cls(PdfParser.interpret_name(data)) allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} - def __bytes__(self): + def __bytes__(self) -> bytes: result = bytearray(b"/") for b in self.name: if b in self.allowed_chars: @@ -236,19 +246,25 @@ def __bytes__(self): return bytes(result) -class PdfArray(list): - def __bytes__(self): +class PdfArray(list[Any]): + def __bytes__(self) -> bytes: return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" -class PdfDict(collections.UserDict): - def __setattr__(self, key, value): +if TYPE_CHECKING: + _DictBase = collections.UserDict[Union[str, bytes], Any] +else: + _DictBase = collections.UserDict + + +class PdfDict(_DictBase): + def __setattr__(self, key: str, value: Any) -> None: if key == "data": collections.UserDict.__setattr__(self, key, value) else: self[key.encode("us-ascii")] = value - def __getattr__(self, key): + def __getattr__(self, key: str) -> str | time.struct_time: try: value = self[key.encode("us-ascii")] except KeyError as e: @@ -275,7 +291,7 @@ def __getattr__(self, key): value = time.gmtime(calendar.timegm(value) + offset) return value - def __bytes__(self): + def __bytes__(self) -> bytes: out = bytearray(b"<<") for key, value in self.items(): if value is None: @@ -290,36 +306,35 @@ def __bytes__(self): class PdfBinary: - def __init__(self, data): + def __init__(self, data: list[int] | bytes) -> None: self.data = data - def __bytes__(self): + def __bytes__(self) -> bytes: return b"<%s>" % b"".join(b"%02X" % b for b in self.data) class PdfStream: - def __init__(self, dictionary, buf): + def __init__(self, dictionary: PdfDict, buf: bytes) -> None: self.dictionary = dictionary self.buf = buf - def decode(self): + def decode(self) -> bytes: try: - filter = self.dictionary.Filter - except AttributeError: + filter = self.dictionary[b"Filter"] + except KeyError: return self.buf if filter == b"FlateDecode": try: - expected_length = self.dictionary.DL - except AttributeError: - expected_length = self.dictionary.Length + expected_length = self.dictionary[b"DL"] + except KeyError: + expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) else: - raise NotImplementedError( - f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" - ) + msg = f"stream filter {repr(filter)} unknown/unsupported" + raise NotImplementedError(msg) -def pdf_repr(x): +def pdf_repr(x: Any) -> bytes: if x is True: return b"true" elif x is False: @@ -328,7 +343,7 @@ def pdf_repr(x): return b"null" elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)): return bytes(x) - elif isinstance(x, int): + elif isinstance(x, (int, float)): return str(x).encode("us-ascii") elif isinstance(x, time.struct_time): return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")" @@ -354,11 +369,19 @@ class PdfParser: Supports PDF up to 1.4 """ - def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): + def __init__( + self, + filename: str | None = None, + f: IO[bytes] | None = None, + buf: bytes | bytearray | None = None, + start_offset: int = 0, + mode: str = "rb", + ) -> None: if buf and f: - raise RuntimeError("specify buf or f or filename, but not both buf and f") + msg = "specify buf or f or filename, but not both buf and f" + raise RuntimeError(msg) self.filename = filename - self.buf = buf + self.buf: bytes | bytearray | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -367,12 +390,16 @@ def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): self.f = f = open(filename, mode) self.should_close_file = True if f is not None: - self.buf = buf = self.get_buf_from_file(f) + self.buf = self.get_buf_from_file(f) self.should_close_buf = True if not filename and hasattr(f, "name"): self.filename = f.name - self.cached_objects = {} - if buf: + self.cached_objects: dict[IndirectReference, Any] = {} + self.root_ref: IndirectReference | None + self.info_ref: IndirectReference | None + self.pages_ref: IndirectReference | None + self.last_xref_section_offset: int | None + if self.buf: self.read_pdf_info() else: self.file_size_total = self.file_size_this = 0 @@ -380,52 +407,53 @@ def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): self.root_ref = None self.info = PdfDict() self.info_ref = None - self.page_tree_root = {} - self.pages = [] - self.orig_pages = [] + self.page_tree_root = PdfDict() + self.pages: list[IndirectReference] = [] + self.orig_pages: list[IndirectReference] = [] self.pages_ref = None self.last_xref_section_offset = None - self.trailer_dict = {} + self.trailer_dict: dict[bytes, Any] = {} self.xref_table = XrefTable() self.xref_table.reading_finished = True if f: self.seek_end() - def __enter__(self): + def __enter__(self) -> PdfParser: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: self.close() - return False # do not suppress exceptions - def start_writing(self): + def start_writing(self) -> None: self.close_buf() self.seek_end() - def close_buf(self): - try: + def close_buf(self) -> None: + if isinstance(self.buf, mmap.mmap): self.buf.close() - except AttributeError: - pass self.buf = None - def close(self): + def close(self) -> None: if self.should_close_buf: self.close_buf() if self.f is not None and self.should_close_file: self.f.close() self.f = None - def seek_end(self): + def seek_end(self) -> None: + assert self.f is not None self.f.seek(0, os.SEEK_END) - def write_header(self): + def write_header(self) -> None: + assert self.f is not None self.f.write(b"%PDF-1.4\n") - def write_comment(self, s): - self.f.write(f"% {s}\n".encode("utf-8")) + def write_comment(self, s: str) -> None: + assert self.f is not None + self.f.write(f"% {s}\n".encode()) - def write_catalog(self): + def write_catalog(self) -> IndirectReference: + assert self.f is not None self.del_root() self.root_ref = self.next_object_id(self.f.tell()) self.pages_ref = self.next_object_id(0) @@ -439,7 +467,7 @@ def write_catalog(self): ) return self.root_ref - def rewrite_pages(self): + def rewrite_pages(self) -> None: pages_tree_nodes_to_delete = [] for i, page_ref in enumerate(self.orig_pages): page_info = self.cached_objects[page_ref] @@ -468,7 +496,10 @@ def rewrite_pages(self): pages_tree_node_ref = pages_tree_node.get(b"Parent", None) self.orig_pages = [] - def write_xref_and_trailer(self, new_root_ref=None): + def write_xref_and_trailer( + self, new_root_ref: IndirectReference | None = None + ) -> None: + assert self.f is not None if new_root_ref: self.del_root() self.root_ref = new_root_ref @@ -476,7 +507,10 @@ def write_xref_and_trailer(self, new_root_ref=None): self.info_ref = self.write_obj(None, self.info) start_xref = self.xref_table.write(self.f) num_entries = len(self.xref_table) - trailer_dict = {b"Root": self.root_ref, b"Size": num_entries} + trailer_dict: dict[str | bytes, Any] = { + b"Root": self.root_ref, + b"Size": num_entries, + } if self.last_xref_section_offset is not None: trailer_dict[b"Prev"] = self.last_xref_section_offset if self.info: @@ -488,16 +522,20 @@ def write_xref_and_trailer(self, new_root_ref=None): + b"\nstartxref\n%d\n%%%%EOF" % start_xref ) - def write_page(self, ref, *objs, **dict_obj): - if isinstance(ref, int): - ref = self.pages[ref] + def write_page( + self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + obj_ref = self.pages[ref] if isinstance(ref, int) else ref if "Type" not in dict_obj: dict_obj["Type"] = PdfName(b"Page") if "Parent" not in dict_obj: dict_obj["Parent"] = self.pages_ref - return self.write_obj(ref, *objs, **dict_obj) + return self.write_obj(obj_ref, *objs, **dict_obj) - def write_obj(self, ref, *objs, **dict_obj): + def write_obj( + self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + assert self.f is not None f = self.f if ref is None: ref = self.next_object_id(f.tell()) @@ -518,14 +556,14 @@ def write_obj(self, ref, *objs, **dict_obj): f.write(b"endobj\n") return ref - def del_root(self): + def del_root(self) -> None: if self.root_ref is None: return del self.xref_table[self.root_ref.object_id] del self.xref_table[self.root[b"Pages"].object_id] @staticmethod - def get_buf_from_file(f): + def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap: if hasattr(f, "getbuffer"): return f.getbuffer() elif hasattr(f, "getvalue"): @@ -536,11 +574,16 @@ def get_buf_from_file(f): except ValueError: # cannot mmap an empty file return b"" - def read_pdf_info(self): + def read_pdf_info(self) -> None: + assert self.buf is not None self.file_size_total = len(self.buf) self.file_size_this = self.file_size_total - self.start_offset self.read_trailer() + check_format_condition( + self.trailer_dict.get(b"Root") is not None, "Root is missing" + ) self.root_ref = self.trailer_dict[b"Root"] + assert self.root_ref is not None self.info_ref = self.trailer_dict.get(b"Info", None) self.root = PdfDict(self.read_indirect(self.root_ref)) if self.info_ref is None: @@ -551,12 +594,15 @@ def read_pdf_info(self): check_format_condition( self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" ) - check_format_condition(b"Pages" in self.root, "/Pages missing in Root") + check_format_condition( + self.root.get(b"Pages") is not None, "/Pages missing in Root" + ) check_format_condition( isinstance(self.root[b"Pages"], IndirectReference), "/Pages in Root is not an indirect reference", ) self.pages_ref = self.root[b"Pages"] + assert self.pages_ref is not None self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) # save the original list of page references @@ -564,7 +610,7 @@ def read_pdf_info(self): # and we need to rewrite the pages and their list self.orig_pages = self.pages[:] - def next_object_id(self, offset=None): + def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) @@ -574,50 +620,53 @@ def next_object_id(self, offset=None): self.xref_table[reference.object_id] = (offset, 0) return reference - delimiter = br"[][()<>{}/%]" - delimiter_or_ws = br"[][()<>{}/%\000\011\012\014\015\040]" - whitespace = br"[\000\011\012\014\015\040]" - whitespace_or_hex = br"[\000\011\012\014\015\0400-9a-fA-F]" + delimiter = rb"[][()<>{}/%]" + delimiter_or_ws = rb"[][()<>{}/%\000\011\012\014\015\040]" + whitespace = rb"[\000\011\012\014\015\040]" + whitespace_or_hex = rb"[\000\011\012\014\015\0400-9a-fA-F]" whitespace_optional = whitespace + b"*" whitespace_mandatory = whitespace + b"+" - newline_only = br"[\r\n]+" - newline = whitespace_optional + newline_only + whitespace_optional + # No "\012" aka "\n" or "\015" aka "\r": + whitespace_optional_no_nl = rb"[\000\011\014\040]*" + newline_only = rb"[\r\n]+" + newline = whitespace_optional_no_nl + newline_only + whitespace_optional_no_nl re_trailer_end = re.compile( whitespace_mandatory - + br"trailer" + + rb"trailer" + whitespace_optional - + br"\<\<(.*\>\>)" + + rb"<<(.*>>)" + newline - + br"startxref" + + rb"startxref" + newline - + br"([0-9]+)" + + rb"([0-9]+)" + newline - + br"%%EOF" + + rb"%%EOF" + whitespace_optional - + br"$", + + rb"$", re.DOTALL, ) re_trailer_prev = re.compile( whitespace_optional - + br"trailer" + + rb"trailer" + whitespace_optional - + br"\<\<(.*?\>\>)" + + rb"<<(.*?>>)" + newline - + br"startxref" + + rb"startxref" + newline - + br"([0-9]+)" + + rb"([0-9]+)" + newline - + br"%%EOF" + + rb"%%EOF" + whitespace_optional, re.DOTALL, ) - def read_trailer(self): + def read_trailer(self) -> None: + assert self.buf is not None search_start_offset = len(self.buf) - 16384 if search_start_offset < self.start_offset: search_start_offset = self.start_offset m = self.re_trailer_end.search(self.buf, search_start_offset) - check_format_condition(m, "trailer end not found") + check_format_condition(m is not None, "trailer end not found") # make sure we found the LAST trailer last_match = m while m: @@ -625,6 +674,7 @@ def read_trailer(self): m = self.re_trailer_end.search(self.buf, m.start() + 16) if not m: m = last_match + assert m is not None trailer_data = m.group(1) self.last_xref_section_offset = int(m.group(2)) self.trailer_dict = self.interpret_trailer(trailer_data) @@ -633,12 +683,14 @@ def read_trailer(self): if b"Prev" in self.trailer_dict: self.read_prev_trailer(self.trailer_dict[b"Prev"]) - def read_prev_trailer(self, xref_section_offset): + def read_prev_trailer(self, xref_section_offset: int) -> None: + assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) m = self.re_trailer_prev.search( self.buf[trailer_offset : trailer_offset + 16384] ) - check_format_condition(m, "previous trailer not found") + check_format_condition(m is not None, "previous trailer not found") + assert m is not None trailer_data = m.group(1) check_format_condition( int(m.group(2)) == xref_section_offset, @@ -651,15 +703,15 @@ def read_prev_trailer(self, xref_section_offset): re_whitespace_optional = re.compile(whitespace_optional) re_name = re.compile( whitespace_optional - + br"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?=" + + rb"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?=" + delimiter_or_ws - + br")" + + rb")" ) - re_dict_start = re.compile(whitespace_optional + br"\<\<") - re_dict_end = re.compile(whitespace_optional + br"\>\>" + whitespace_optional) + re_dict_start = re.compile(whitespace_optional + rb"<<") + re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) @classmethod - def interpret_trailer(cls, trailer_data): + def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]: trailer = {} offset = 0 while True: @@ -667,14 +719,18 @@ def interpret_trailer(cls, trailer_data): if not m: m = cls.re_dict_end.match(trailer_data, offset) check_format_condition( - m and m.end() == len(trailer_data), + m is not None and m.end() == len(trailer_data), "name not found in trailer, remaining data: " + repr(trailer_data[offset:]), ) break key = cls.interpret_name(m.group(1)) - value, offset = cls.get_value(trailer_data, m.end()) + assert isinstance(key, bytes) + value, value_offset = cls.get_value(trailer_data, m.end()) trailer[key] = value + if value_offset is None: + break + offset = value_offset check_format_condition( b"Size" in trailer and isinstance(trailer[b"Size"], int), "/Size not in trailer or not an integer", @@ -685,10 +741,10 @@ def interpret_trailer(cls, trailer_data): ) return trailer - re_hashes_in_name = re.compile(br"([^#]*)(#([0-9a-fA-F]{2}))?") + re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") @classmethod - def interpret_name(cls, raw, as_text=False): + def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes: name = b"" for m in cls.re_hashes_in_name.finditer(raw): if m.group(3): @@ -700,57 +756,63 @@ def interpret_name(cls, raw, as_text=False): else: return bytes(name) - re_null = re.compile(whitespace_optional + br"null(?=" + delimiter_or_ws + br")") - re_true = re.compile(whitespace_optional + br"true(?=" + delimiter_or_ws + br")") - re_false = re.compile(whitespace_optional + br"false(?=" + delimiter_or_ws + br")") + re_null = re.compile(whitespace_optional + rb"null(?=" + delimiter_or_ws + rb")") + re_true = re.compile(whitespace_optional + rb"true(?=" + delimiter_or_ws + rb")") + re_false = re.compile(whitespace_optional + rb"false(?=" + delimiter_or_ws + rb")") re_int = re.compile( - whitespace_optional + br"([-+]?[0-9]+)(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"([-+]?[0-9]+)(?=" + delimiter_or_ws + rb")" ) re_real = re.compile( whitespace_optional - + br"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?=" + + rb"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?=" + delimiter_or_ws - + br")" + + rb")" ) - re_array_start = re.compile(whitespace_optional + br"\[") - re_array_end = re.compile(whitespace_optional + br"]") + re_array_start = re.compile(whitespace_optional + rb"\[") + re_array_end = re.compile(whitespace_optional + rb"]") re_string_hex = re.compile( - whitespace_optional + br"\<(" + whitespace_or_hex + br"*)\>" + whitespace_optional + rb"<(" + whitespace_or_hex + rb"*)>" ) - re_string_lit = re.compile(whitespace_optional + br"\(") + re_string_lit = re.compile(whitespace_optional + rb"\(") re_indirect_reference = re.compile( whitespace_optional - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"R(?=" + + rb"R(?=" + delimiter_or_ws - + br")" + + rb")" ) re_indirect_def_start = re.compile( whitespace_optional - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"obj(?=" + + rb"obj(?=" + delimiter_or_ws - + br")" + + rb")" ) re_indirect_def_end = re.compile( - whitespace_optional + br"endobj(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"endobj(?=" + delimiter_or_ws + rb")" ) re_comment = re.compile( - br"(" + whitespace_optional + br"%[^\r\n]*" + newline + br")*" + rb"(" + whitespace_optional + rb"%[^\r\n]*" + newline + rb")*" ) - re_stream_start = re.compile(whitespace_optional + br"stream\r?\n") + re_stream_start = re.compile(whitespace_optional + rb"stream\r?\n") re_stream_end = re.compile( - whitespace_optional + br"endstream(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"endstream(?=" + delimiter_or_ws + rb")" ) @classmethod - def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): + def get_value( + cls, + data: bytes | bytearray | mmap.mmap, + offset: int, + expect_indirect: IndirectReference | None = None, + max_nesting: int = -1, + ) -> tuple[Any, int | None]: if max_nesting == 0: return None, None m = cls.re_comment.match(data, offset) @@ -772,11 +834,16 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): == IndirectReference(int(m.group(1)), int(m.group(2))), "indirect object definition different than expected", ) - object, offset = cls.get_value(data, m.end(), max_nesting=max_nesting - 1) - if offset is None: + object, object_offset = cls.get_value( + data, m.end(), max_nesting=max_nesting - 1 + ) + if object_offset is None: return object, None - m = cls.re_indirect_def_end.match(data, offset) - check_format_condition(m, "indirect object definition end not found") + m = cls.re_indirect_def_end.match(data, object_offset) + check_format_condition( + m is not None, "indirect object definition end not found" + ) + assert m is not None return object, m.end() check_format_condition( not expect_indirect, "indirect object definition not found" @@ -795,47 +862,53 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): m = cls.re_dict_start.match(data, offset) if m: offset = m.end() - result = {} + result: dict[Any, Any] = {} m = cls.re_dict_end.match(data, offset) + current_offset: int | None = offset while not m: - key, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - if offset is None: + assert current_offset is not None + key, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + if current_offset is None: return result, None - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) result[key] = value - if offset is None: + if current_offset is None: return result, None - m = cls.re_dict_end.match(data, offset) - offset = m.end() - m = cls.re_stream_start.match(data, offset) + m = cls.re_dict_end.match(data, current_offset) + current_offset = m.end() + m = cls.re_stream_start.match(data, current_offset) if m: - try: - stream_len = int(result[b"Length"]) - except (TypeError, KeyError, ValueError) as e: - raise PdfFormatError( - "bad or missing Length in stream dict (%r)" - % result.get(b"Length", None) - ) from e + stream_len = result.get(b"Length") + if stream_len is None or not isinstance(stream_len, int): + msg = f"bad or missing Length in stream dict ({stream_len})" + raise PdfFormatError(msg) stream_data = data[m.end() : m.end() + stream_len] m = cls.re_stream_end.match(data, m.end() + stream_len) - check_format_condition(m, "stream end not found") - offset = m.end() - result = PdfStream(PdfDict(result), stream_data) - else: - result = PdfDict(result) - return result, offset + check_format_condition(m is not None, "stream end not found") + assert m is not None + current_offset = m.end() + return PdfStream(PdfDict(result), stream_data), current_offset + return PdfDict(result), current_offset m = cls.re_array_start.match(data, offset) if m: offset = m.end() - result = [] + results = [] m = cls.re_array_end.match(data, offset) + current_offset = offset while not m: - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - result.append(value) - if offset is None: - return result, None - m = cls.re_array_end.match(data, offset) - return result, m.end() + assert current_offset is not None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + results.append(value) + if current_offset is None: + return results, None + m = cls.re_array_end.match(data, current_offset) + return results, m.end() m = cls.re_null.match(data, offset) if m: return None, m.end() @@ -859,7 +932,7 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): if m: # filter out whitespace hex_string = bytearray( - [b for b in m.group(1) if b in b"0123456789abcdefABCDEF"] + b for b in m.group(1) if b in b"0123456789abcdefABCDEF" ) if len(hex_string) % 2 == 1: # append a 0 if the length is not even - yes, at the end @@ -869,10 +942,11 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): if m: return cls.get_literal_string(data, m.end()) # return None, offset # fallback (only for debugging) - raise PdfFormatError("unrecognized object: " + repr(data[offset : offset + 32])) + msg = f"unrecognized object: {repr(data[offset : offset + 32])}" + raise PdfFormatError(msg) re_lit_str_token = re.compile( - br"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" + rb"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" ) escaped_chars = { b"n": b"\n", @@ -894,7 +968,9 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): } @classmethod - def get_literal_string(cls, data, offset): + def get_literal_string( + cls, data: bytes | bytearray | mmap.mmap, offset: int + ) -> tuple[bytes, int]: nesting_depth = 0 result = bytearray() for m in cls.re_lit_str_token.finditer(data, offset): @@ -916,25 +992,28 @@ def get_literal_string(cls, data, offset): result.extend(b")") nesting_depth -= 1 offset = m.end() - raise PdfFormatError("unfinished literal string") + msg = "unfinished literal string" + raise PdfFormatError(msg) - re_xref_section_start = re.compile(whitespace_optional + br"xref" + newline) + re_xref_section_start = re.compile(whitespace_optional + rb"xref" + newline) re_xref_subsection_start = re.compile( whitespace_optional - + br"([0-9]+)" + + rb"([0-9]+)" + whitespace_mandatory - + br"([0-9]+)" + + rb"([0-9]+)" + whitespace_optional + newline_only ) - re_xref_entry = re.compile(br"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") + re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") - def read_xref_table(self, xref_section_offset): + def read_xref_table(self, xref_section_offset: int) -> int: + assert self.buf is not None subsection_found = False m = self.re_xref_section_start.match( self.buf, xref_section_offset + self.start_offset ) - check_format_condition(m, "xref section start not found") + check_format_condition(m is not None, "xref section start not found") + assert m is not None offset = m.end() while True: m = self.re_xref_subsection_start.match(self.buf, offset) @@ -949,26 +1028,25 @@ def read_xref_table(self, xref_section_offset): num_objects = int(m.group(2)) for i in range(first_object, first_object + num_objects): m = self.re_xref_entry.match(self.buf, offset) - check_format_condition(m, "xref entry not found") + check_format_condition(m is not None, "xref entry not found") + assert m is not None offset = m.end() is_free = m.group(3) == b"f" - generation = int(m.group(2)) if not is_free: + generation = int(m.group(2)) new_entry = (int(m.group(1)), generation) - check_format_condition( - i not in self.xref_table or self.xref_table[i] == new_entry, - "xref entry duplicated (and not identical)", - ) - self.xref_table[i] = new_entry + if i not in self.xref_table: + self.xref_table[i] = new_entry return offset - def read_indirect(self, ref, max_nesting=-1): + def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any: offset, generation = self.xref_table[ref[0]] check_format_condition( generation == ref[1], f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " f"table, instead found generation {generation} at offset {offset}", ) + assert self.buf is not None value = self.get_value( self.buf, offset + self.start_offset, @@ -978,14 +1056,15 @@ def read_indirect(self, ref, max_nesting=-1): self.cached_objects[ref] = value return value - def linearize_page_tree(self, node=None): - if node is None: - node = self.page_tree_root + def linearize_page_tree( + self, node: PdfDict | None = None + ) -> list[IndirectReference]: + page_node = node if node is not None else self.page_tree_root check_format_condition( - node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" + page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] - for kid in node[b"Kids"]: + for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) diff --git a/dependencies/OSX/PIL/PixarImagePlugin.py b/dependencies/OSX/PIL/PixarImagePlugin.py index 91f0314..5c465bb 100644 --- a/dependencies/OSX/PIL/PixarImagePlugin.py +++ b/dependencies/OSX/PIL/PixarImagePlugin.py @@ -18,6 +18,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile from ._binary import i16le as i16 @@ -26,7 +27,7 @@ # helpers -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"\200\350\000\000" @@ -35,31 +36,32 @@ def _accept(prefix): class PixarImageFile(ImageFile.ImageFile): - format = "PIXAR" format_description = "PIXAR raster image" - def _open(self): - + def _open(self) -> None: # assuming a 4-byte magic label + assert self.fp is not None + s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a PIXAR file") + msg = "not a PIXAR file" + raise SyntaxError(msg) # read rest of header s = s + self.fp.read(508) - self._size = i16(s[418:420]), i16(s[416:418]) + self._size = i16(s, 418), i16(s, 416) # get channel/depth descriptions - mode = i16(s[424:426]), i16(s[426:428]) + mode = i16(s, 424), i16(s, 426) if mode == (14, 2): - self.mode = "RGB" + self._mode = "RGB" # FIXME: to be continued... # create tile descriptor (assuming "dumped") - self.tile = [("raw", (0, 0) + self.size, 1024, (self.mode, 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1024, self.mode)] # diff --git a/dependencies/OSX/PIL/PngImagePlugin.py b/dependencies/OSX/PIL/PngImagePlugin.py index 6af41af..4b97992 100644 --- a/dependencies/OSX/PIL/PngImagePlugin.py +++ b/dependencies/OSX/PIL/PngImagePlugin.py @@ -30,6 +30,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import logging @@ -37,18 +38,23 @@ import struct import warnings import zlib +from collections.abc import Callable +from enum import IntEnum +from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence -from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +if TYPE_CHECKING: + from . import _imaging + logger = logging.getLogger(__name__) -is_cid = re.compile(br"\w\w\w\w").match +is_cid = re.compile(rb"\w\w\w\w").match _MAGIC = b"\211PNG\r\n\032\n" @@ -56,12 +62,12 @@ _MODES = { # supported bits/color combinations, and corresponding modes/rawmodes - # Greyscale + # Grayscale (1, 0): ("1", "1"), (2, 0): ("L", "L;2"), (4, 0): ("L", "L;4"), (8, 0): ("L", "L"), - (16, 0): ("I", "I;16B"), + (16, 0): ("I;16", "I;16B"), # Truecolour (8, 2): ("RGB", "RGB"), (16, 2): ("RGB", "RGB;16B"), @@ -70,7 +76,7 @@ (2, 3): ("P", "P;2"), (4, 3): ("P", "P;4"), (8, 3): ("P", "P"), - # Greyscale with alpha + # Grayscale with alpha (8, 4): ("LA", "LA"), (16, 4): ("RGBA", "LA;16B"), # LA;16B->LA not yet available # Truecolour with alpha @@ -95,47 +101,51 @@ # APNG frame disposal modes -APNG_DISPOSE_OP_NONE = 0 -""" -No disposal is done on this frame before rendering the next frame. -See :ref:`Saving APNG sequences`. -""" -APNG_DISPOSE_OP_BACKGROUND = 1 -""" -This frame’s modified region is cleared to fully transparent black before rendering -the next frame. -See :ref:`Saving APNG sequences`. -""" -APNG_DISPOSE_OP_PREVIOUS = 2 -""" -This frame’s modified region is reverted to the previous frame’s contents before -rendering the next frame. -See :ref:`Saving APNG sequences`. -""" +class Disposal(IntEnum): + OP_NONE = 0 + """ + No disposal is done on this frame before rendering the next frame. + See :ref:`Saving APNG sequences`. + """ + OP_BACKGROUND = 1 + """ + This frame’s modified region is cleared to fully transparent black before rendering + the next frame. + See :ref:`Saving APNG sequences`. + """ + OP_PREVIOUS = 2 + """ + This frame’s modified region is reverted to the previous frame’s contents before + rendering the next frame. + See :ref:`Saving APNG sequences`. + """ + # APNG frame blend modes -APNG_BLEND_OP_SOURCE = 0 -""" -All color components of this frame, including alpha, overwrite the previous output -image contents. -See :ref:`Saving APNG sequences`. -""" -APNG_BLEND_OP_OVER = 1 -""" -This frame should be alpha composited with the previous output image contents. -See :ref:`Saving APNG sequences`. -""" +class Blend(IntEnum): + OP_SOURCE = 0 + """ + All color components of this frame, including alpha, overwrite the previous output + image contents. + See :ref:`Saving APNG sequences`. + """ + OP_OVER = 1 + """ + This frame should be alpha composited with the previous output image contents. + See :ref:`Saving APNG sequences`. + """ -def _safe_zlib_decompress(s): +def _safe_zlib_decompress(s: bytes) -> bytes: dobj = zlib.decompressobj() plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) if dobj.unconsumed_tail: - raise ValueError("Decompressed Data Too Large") + msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK" + raise ValueError(msg) return plaintext -def _crc32(data, seed=0): +def _crc32(data: bytes, seed: int = 0) -> int: return zlib.crc32(data, seed) & 0xFFFFFFFF @@ -144,15 +154,15 @@ def _crc32(data, seed=0): class ChunkStream: - def __init__(self, fp): - - self.fp = fp - self.queue = [] + def __init__(self, fp: IO[bytes]) -> None: + self.fp: IO[bytes] | None = fp + self.queue: list[tuple[bytes, int, int]] | None = [] - def read(self): + def read(self) -> tuple[bytes, int, int]: """Fetch a new chunk. Returns header information.""" cid = None + assert self.fp is not None if self.queue: cid, pos, length = self.queue.pop() self.fp.seek(pos) @@ -164,68 +174,70 @@ def read(self): if not is_cid(cid): if not ImageFile.LOAD_TRUNCATED_IMAGES: - raise SyntaxError(f"broken PNG file (chunk {repr(cid)})") + msg = f"broken PNG file (chunk {repr(cid)})" + raise SyntaxError(msg) return cid, pos, length - def __enter__(self): + def __enter__(self) -> ChunkStream: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() - def close(self): - self.queue = self.crc = self.fp = None - - def push(self, cid, pos, length): + def close(self) -> None: + self.queue = self.fp = None + def push(self, cid: bytes, pos: int, length: int) -> None: + assert self.queue is not None self.queue.append((cid, pos, length)) - def call(self, cid, pos, length): + def call(self, cid: bytes, pos: int, length: int) -> bytes: """Call the appropriate chunk handler""" logger.debug("STREAM %r %s %s", cid, pos, length) - return getattr(self, "chunk_" + cid.decode("ascii"))(pos, length) + return getattr(self, f"chunk_{cid.decode('ascii')}")(pos, length) - def crc(self, cid, data): + def crc(self, cid: bytes, data: bytes) -> None: """Read and verify checksum""" # Skip CRC checks for ancillary chunks if allowed to load truncated # images # 5th byte of first char is 1 [specs, section 5.4] - if ImageFile.LOAD_TRUNCATED_IMAGES and (i8(cid[0]) >> 5 & 1): + if ImageFile.LOAD_TRUNCATED_IMAGES and (cid[0] >> 5 & 1): self.crc_skip(cid, data) return + assert self.fp is not None try: crc1 = _crc32(data, _crc32(cid)) crc2 = i32(self.fp.read(4)) if crc1 != crc2: - raise SyntaxError( - f"broken PNG file (bad header checksum in {repr(cid)})" - ) + msg = f"broken PNG file (bad header checksum in {repr(cid)})" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError( - f"broken PNG file (incomplete checksum in {repr(cid)})" - ) from e + msg = f"broken PNG file (incomplete checksum in {repr(cid)})" + raise SyntaxError(msg) from e - def crc_skip(self, cid, data): - """Read checksum. Used if the C module is not present""" + def crc_skip(self, cid: bytes, data: bytes) -> None: + """Read checksum""" + assert self.fp is not None self.fp.read(4) - def verify(self, endchunk=b"IEND"): - + def verify(self, endchunk: bytes = b"IEND") -> list[bytes]: # Simple approach; just calculate checksum for all remaining # blocks. Must be called directly after open. cids = [] + assert self.fp is not None while True: try: cid, pos, length = self.read() except struct.error as e: - raise OSError("truncated PNG file") from e + msg = "truncated PNG file" + raise OSError(msg) from e if cid == endchunk: break @@ -242,8 +254,13 @@ class iTXt(str): """ + lang: str | bytes | None + tkey: str | bytes | None + @staticmethod - def __new__(cls, text, lang=None, tkey=None): + def __new__( + cls, text: str, lang: str | None = None, tkey: str | None = None + ) -> iTXt: """ :param cls: the class to use when creating the instance :param text: value for this key @@ -263,10 +280,10 @@ class PngInfo: """ - def __init__(self): - self.chunks = [] + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] - def add(self, cid, data, after_idat=False): + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: """Appends an arbitrary chunk. Use with caution. :param cid: a byte string, 4 bytes long. @@ -276,12 +293,16 @@ def add(self, cid, data, after_idat=False): """ - chunk = [cid, data] - if after_idat: - chunk.append(True) - self.chunks.append(tuple(chunk)) + self.chunks.append((cid, data, after_idat)) - def add_itxt(self, key, value, lang="", tkey="", zip=False): + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: """Appends an iTXt chunk. :param key: latin-1 encodable text key name @@ -309,7 +330,9 @@ def add_itxt(self, key, value, lang="", tkey="", zip=False): else: self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) - def add_text(self, key, value, zip=False): + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: """Appends a text chunk. :param key: latin-1 encodable text key name @@ -319,7 +342,13 @@ def add_text(self, key, value, zip=False): """ if isinstance(value, iTXt): - return self.add_itxt(key, value, value.lang, value.tkey, zip=zip) + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) # The tEXt chunk stores latin-1 text if not isinstance(value, bytes): @@ -341,47 +370,54 @@ def add_text(self, key, value, zip=False): # PNG image stream (IHDR/IEND) +class _RewindState(NamedTuple): + info: dict[str | tuple[int, int], Any] + tile: list[ImageFile._Tile] + seq_num: int | None + + class PngStream(ChunkStream): - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: super().__init__(fp) # local copies of Image attributes - self.im_info = {} - self.im_text = {} + self.im_info: dict[str | tuple[int, int], Any] = {} + self.im_text: dict[str, str | iTXt] = {} self.im_size = (0, 0) - self.im_mode = None - self.im_tile = None - self.im_palette = None - self.im_custom_mimetype = None - self.im_n_frames = None - self._seq_num = None - self.rewind_state = None + self.im_mode = "" + self.im_tile: list[ImageFile._Tile] = [] + self.im_palette: tuple[str, bytes] | None = None + self.im_custom_mimetype: str | None = None + self.im_n_frames: int | None = None + self._seq_num: int | None = None + self.rewind_state = _RewindState({}, [], None) self.text_memory = 0 - def check_text_memory(self, chunklen): + def check_text_memory(self, chunklen: int) -> None: self.text_memory += chunklen if self.text_memory > MAX_TEXT_MEMORY: - raise ValueError( + msg = ( "Too much memory used in text chunks: " f"{self.text_memory}>MAX_TEXT_MEMORY" ) + raise ValueError(msg) - def save_rewind(self): - self.rewind_state = { - "info": self.im_info.copy(), - "tile": self.im_tile, - "seq_num": self._seq_num, - } - - def rewind(self): - self.im_info = self.rewind_state["info"] - self.im_tile = self.rewind_state["tile"] - self._seq_num = self.rewind_state["seq_num"] + def save_rewind(self) -> None: + self.rewind_state = _RewindState( + self.im_info.copy(), + self.im_tile, + self._seq_num, + ) - def chunk_iCCP(self, pos, length): + def rewind(self) -> None: + self.im_info = self.rewind_state.info.copy() + self.im_tile = self.rewind_state.tile + self._seq_num = self.rewind_state.seq_num + def chunk_iCCP(self, pos: int, length: int) -> bytes: # ICC profile + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) # according to PNG spec, the iCCP chunk contains: # Profile name 1-79 bytes (character string) @@ -390,10 +426,11 @@ def chunk_iCCP(self, pos, length): # Compressed profile n bytes (zlib with deflate compression) i = s.find(b"\0") logger.debug("iCCP profile name %r", s[:i]) - logger.debug("Compression method %s", i8(s[i])) - comp_method = i8(s[i]) + comp_method = s[i + 1] + logger.debug("Compression method %s", comp_method) if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in iCCP chunk") + msg = f"Unknown compression method {comp_method} in iCCP chunk" + raise SyntaxError(msg) try: icc_profile = _safe_zlib_decompress(s[i + 2 :]) except ValueError: @@ -406,50 +443,55 @@ def chunk_iCCP(self, pos, length): self.im_info["icc_profile"] = icc_profile return s - def chunk_IHDR(self, pos, length): - + def chunk_IHDR(self, pos: int, length: int) -> bytes: # image header + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - self.im_size = i32(s), i32(s[4:]) + if length < 13: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated IHDR chunk" + raise ValueError(msg) + self.im_size = i32(s, 0), i32(s, 4) try: - self.im_mode, self.im_rawmode = _MODES[(i8(s[8]), i8(s[9]))] + self.im_mode, self.im_rawmode = _MODES[(s[8], s[9])] except Exception: pass - if i8(s[12]): + if s[12]: self.im_info["interlace"] = 1 - if i8(s[11]): - raise SyntaxError("unknown filter category") + if s[11]: + msg = "unknown filter category" + raise SyntaxError(msg) return s - def chunk_IDAT(self, pos, length): - + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: - tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", self.im_info["bbox"], pos, self.im_rawmode)] else: if self.im_n_frames is not None: self.im_info["default_image"] = True - tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] self.im_tile = tile self.im_idat = length - raise EOFError - - def chunk_IEND(self, pos, length): + msg = "image data found" + raise EOFError(msg) - # end of PNG image - raise EOFError - - def chunk_PLTE(self, pos, length): + def chunk_IEND(self, pos: int, length: int) -> NoReturn: + msg = "end of PNG image" + raise EOFError(msg) + def chunk_PLTE(self, pos: int, length: int) -> bytes: # palette + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": self.im_palette = "RGB", s return s - def chunk_tRNS(self, pos, length): - + def chunk_tRNS(self, pos: int, length: int) -> bytes: # transparency + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": if _simple_palette.match(s): @@ -462,54 +504,67 @@ def chunk_tRNS(self, pos, length): # otherwise, we have a byte string with one alpha value # for each palette entry self.im_info["transparency"] = s - elif self.im_mode in ("1", "L", "I"): + elif self.im_mode in ("1", "L", "I;16"): self.im_info["transparency"] = i16(s) elif self.im_mode == "RGB": - self.im_info["transparency"] = i16(s), i16(s[2:]), i16(s[4:]) + self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) return s - def chunk_gAMA(self, pos, length): + def chunk_gAMA(self, pos: int, length: int) -> bytes: # gamma setting + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["gamma"] = i32(s) / 100000.0 return s - def chunk_cHRM(self, pos, length): + def chunk_cHRM(self, pos: int, length: int) -> bytes: # chromaticity, 8 unsigned ints, actual value is scaled by 100,000 # WP x,y, Red x,y, Green x,y Blue x,y + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - raw_vals = struct.unpack(">%dI" % (len(s) // 4), s) + raw_vals = struct.unpack(f">{len(s) // 4}I", s) self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) return s - def chunk_sRGB(self, pos, length): + def chunk_sRGB(self, pos: int, length: int) -> bytes: # srgb rendering intent, 1 byte # 0 perceptual # 1 relative colorimetric # 2 saturation # 3 absolute colorimetric + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - self.im_info["srgb"] = i8(s) + if length < 1: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated sRGB chunk" + raise ValueError(msg) + self.im_info["srgb"] = s[0] return s - def chunk_pHYs(self, pos, length): - + def chunk_pHYs(self, pos: int, length: int) -> bytes: # pixels per unit + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - px, py = i32(s), i32(s[4:]) - unit = i8(s[8]) + if length < 9: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated pHYs chunk" + raise ValueError(msg) + px, py = i32(s, 0), i32(s, 4) + unit = s[8] if unit == 1: # meter - dpi = int(px * 0.0254 + 0.5), int(py * 0.0254 + 0.5) + dpi = px * 0.0254, py * 0.0254 self.im_info["dpi"] = dpi elif unit == 0: self.im_info["aspect"] = px, py return s - def chunk_tEXt(self, pos, length): - + def chunk_tEXt(self, pos: int, length: int) -> bytes: # text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -518,18 +573,18 @@ def chunk_tEXt(self, pos, length): k = s v = b"" if k: - k = k.decode("latin-1", "strict") + k_str = k.decode("latin-1", "strict") v_str = v.decode("latin-1", "replace") - self.im_info[k] = v if k == "exif" else v_str - self.im_text[k] = v_str + self.im_info[k_str] = v if k == b"exif" else v_str + self.im_text[k_str] = v_str self.check_text_memory(len(v_str)) return s - def chunk_zTXt(self, pos, length): - + def chunk_zTXt(self, pos: int, length: int) -> bytes: # compressed text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -537,11 +592,12 @@ def chunk_zTXt(self, pos, length): k = s v = b"" if v: - comp_method = i8(v[0]) + comp_method = v[0] else: comp_method = 0 if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in zTXt chunk") + msg = f"Unknown compression method {comp_method} in zTXt chunk" + raise SyntaxError(msg) try: v = _safe_zlib_decompress(v[1:]) except ValueError: @@ -553,17 +609,17 @@ def chunk_zTXt(self, pos, length): v = b"" if k: - k = k.decode("latin-1", "strict") - v = v.decode("latin-1", "replace") + k_str = k.decode("latin-1", "strict") + v_str = v.decode("latin-1", "replace") - self.im_info[k] = self.im_text[k] = v - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = v_str + self.check_text_memory(len(v_str)) return s - def chunk_iTXt(self, pos, length): - + def chunk_iTXt(self, pos: int, length: int) -> bytes: # international text + assert self.fp is not None r = s = ImageFile._safe_read(self.fp, length) try: k, r = r.split(b"\0", 1) @@ -571,7 +627,7 @@ def chunk_iTXt(self, pos, length): return s if len(r) < 2: return s - cf, cm, r = i8(r[0]), i8(r[1]), r[2:] + cf, cm, r = r[0], r[1], r[2:] try: lang, tk, v = r.split(b"\0", 2) except ValueError: @@ -589,27 +645,36 @@ def chunk_iTXt(self, pos, length): return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: - k = k.decode("latin-1", "strict") - lang = lang.decode("utf-8", "strict") - tk = tk.decode("utf-8", "strict") - v = v.decode("utf-8", "strict") + k_str = k.decode("latin-1", "strict") + lang_str = lang.decode("utf-8", "strict") + tk_str = tk.decode("utf-8", "strict") + v_str = v.decode("utf-8", "strict") except UnicodeError: return s - self.im_info[k] = self.im_text[k] = iTXt(v, lang, tk) - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = iTXt(v_str, lang_str, tk_str) + self.check_text_memory(len(v_str)) return s - def chunk_eXIf(self, pos, length): + def chunk_eXIf(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["exif"] = b"Exif\x00\x00" + s return s # APNG chunks - def chunk_acTL(self, pos, length): + def chunk_acTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 8: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated acTL chunk" + raise ValueError(msg) if self.im_n_frames is not None: self.im_n_frames = None warnings.warn("Invalid APNG, will use default PNG image if possible") @@ -619,37 +684,53 @@ def chunk_acTL(self, pos, length): warnings.warn("Invalid APNG, will use default PNG image if possible") return s self.im_n_frames = n_frames - self.im_info["loop"] = i32(s[4:]) + self.im_info["loop"] = i32(s, 4) self.im_custom_mimetype = "image/apng" return s - def chunk_fcTL(self, pos, length): + def chunk_fcTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 26: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated fcTL chunk" + raise ValueError(msg) seq = i32(s) if (self._seq_num is None and seq != 0) or ( self._seq_num is not None and self._seq_num != seq - 1 ): - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq - width, height = i32(s[4:]), i32(s[8:]) - px, py = i32(s[12:]), i32(s[16:]) + width, height = i32(s, 4), i32(s, 8) + px, py = i32(s, 12), i32(s, 16) im_w, im_h = self.im_size if px + width > im_w or py + height > im_h: - raise SyntaxError("APNG contains invalid frames") + msg = "APNG contains invalid frames" + raise SyntaxError(msg) self.im_info["bbox"] = (px, py, px + width, py + height) - delay_num, delay_den = i16(s[20:]), i16(s[22:]) + delay_num, delay_den = i16(s, 20), i16(s, 22) if delay_den == 0: delay_den = 100 self.im_info["duration"] = float(delay_num) / float(delay_den) * 1000 - self.im_info["disposal"] = i8(s[24]) - self.im_info["blend"] = i8(s[25]) + self.im_info["disposal"] = s[24] + self.im_info["blend"] = s[25] return s - def chunk_fdAT(self, pos, length): + def chunk_fdAT(self, pos: int, length: int) -> bytes: + assert self.fp is not None + if length < 4: + if ImageFile.LOAD_TRUNCATED_IMAGES: + s = ImageFile._safe_read(self.fp, length) + return s + msg = "APNG contains truncated fDAT chunk" + raise ValueError(msg) s = ImageFile._safe_read(self.fp, 4) seq = i32(s) if self._seq_num != seq - 1: - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq return self.chunk_IDAT(pos + 4, length - 4) @@ -658,7 +739,7 @@ def chunk_fdAT(self, pos, length): # PNG reader -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == _MAGIC @@ -667,25 +748,23 @@ def _accept(prefix): class PngImageFile(ImageFile.ImageFile): - format = "PNG" format_description = "Portable network graphics" - def _open(self): - + def _open(self) -> None: if not _accept(self.fp.read(8)): - raise SyntaxError("not a PNG file") - self.__fp = self.fp + msg = "not a PNG file" + raise SyntaxError(msg) + self._fp = self.fp self.__frame = 0 # # Parse headers up to the first IDAT or fDAT chunk - self.private_chunks = [] - self.png = PngStream(self.fp) + self.private_chunks: list[tuple[bytes, bytes] | tuple[bytes, bytes, bool]] = [] + self.png: PngStream | None = PngStream(self.fp) while True: - # # get next chunk @@ -710,10 +789,10 @@ def _open(self): # difficult to break if things go wrong in the decoder... # (believe me, I've tried ;-) - self.mode = self.png.im_mode + self._mode = self.png.im_mode self._size = self.png.im_size self.info = self.png.im_info - self._text = None + self._text: dict[str, str | iTXt] | None = None self.tile = self.png.im_tile self.custom_mimetype = self.png.im_custom_mimetype self.n_frames = self.png.im_n_frames or 1 @@ -732,7 +811,7 @@ def _open(self): self._close_exclusive_fp_after_loading = False self.png.save_rewind() self.__rewind_idat = self.__prepare_idat - self.__rewind = self.__fp.tell() + self.__rewind = self._fp.tell() if self.default_image: # IDAT chunk contains default image and not first animation frame self.n_frames += 1 @@ -740,7 +819,7 @@ def _open(self): self.is_animated = self.n_frames > 1 @property - def text(self): + def text(self) -> dict[str, str | iTXt]: # experimental if self._text is None: # iTxt, tEXt and zTXt chunks may appear at the end of the file @@ -752,17 +831,20 @@ def text(self): self.load() if self.is_animated: self.seek(frame) + assert self._text is not None return self._text - def verify(self): + def verify(self) -> None: """Verify PNG file""" if self.fp is None: - raise RuntimeError("verify must be called directly after open") + msg = "verify must be called directly after open" + raise RuntimeError(msg) # back up to beginning of IDAT block self.fp.seek(self.tile[0][2] - 8) + assert self.png is not None self.png.verify() self.png.close() @@ -770,7 +852,7 @@ def verify(self): self.fp.close() self.fp = None - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: @@ -782,86 +864,111 @@ def seek(self, frame): self._seek(f) except EOFError as e: self.seek(last_frame) - raise EOFError("no more images in APNG file") from e + msg = "no more images in APNG file" + raise EOFError(msg) from e - def _seek(self, frame, rewind=False): + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None + + self.dispose: _imaging.ImagingCore | None + dispose_extent = None if frame == 0: if rewind: - self.__fp.seek(self.__rewind) + self._fp.seek(self.__rewind) self.png.rewind() self.__prepare_idat = self.__rewind_idat - self.im = None - if self.pyaccess: - self.pyaccess = None + self._im = None self.info = self.png.im_info self.tile = self.png.im_tile - self.fp = self.__fp + self.fp = self._fp self._prev_im = None self.dispose = None self.default_image = self.info.get("default_image", False) self.dispose_op = self.info.get("disposal") self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") + dispose_extent = self.info.get("bbox") self.__frame = 0 - return else: if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") - - # ensure previous frame was loaded - self.load() + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) - self.fp = self.__fp + # ensure previous frame was loaded + self.load() - # advance to the next frame - if self.__prepare_idat: - ImageFile._safe_read(self.fp, self.__prepare_idat) - self.__prepare_idat = 0 - frame_start = False - while True: - self.fp.read(4) # CRC + if self.dispose: + self.im.paste(self.dispose, self.dispose_extent) + self._prev_im = self.im.copy() - try: - cid, pos, length = self.png.read() - except (struct.error, SyntaxError): - break + self.fp = self._fp - if cid == b"IEND": - raise EOFError("No more images in APNG file") - if cid == b"fcTL": - if frame_start: - # there must be at least one fdAT chunk between fcTL chunks - raise SyntaxError("APNG missing frame data") - frame_start = True + # advance to the next frame + if self.__prepare_idat: + ImageFile._safe_read(self.fp, self.__prepare_idat) + self.__prepare_idat = 0 + frame_start = False + while True: + self.fp.read(4) # CRC - try: - self.png.call(cid, pos, length) - except UnicodeDecodeError: - break - except EOFError: - if cid == b"fdAT": - length -= 4 + try: + cid, pos, length = self.png.read() + except (struct.error, SyntaxError): + break + + if cid == b"IEND": + msg = "No more images in APNG file" + raise EOFError(msg) + if cid == b"fcTL": if frame_start: - self.__prepare_idat = length - break - ImageFile._safe_read(self.fp, length) - except AttributeError: - logger.debug("%r %s %s (unknown)", cid, pos, length) - ImageFile._safe_read(self.fp, length) - - self.__frame = frame - self.tile = self.png.im_tile - self.dispose_op = self.info.get("disposal") - self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") - - if not self.tile: - raise EOFError + # there must be at least one fdAT chunk between fcTL chunks + msg = "APNG missing frame data" + raise SyntaxError(msg) + frame_start = True - def tell(self): + try: + self.png.call(cid, pos, length) + except UnicodeDecodeError: + break + except EOFError: + if cid == b"fdAT": + length -= 4 + if frame_start: + self.__prepare_idat = length + break + ImageFile._safe_read(self.fp, length) + except AttributeError: + logger.debug("%r %s %s (unknown)", cid, pos, length) + ImageFile._safe_read(self.fp, length) + + self.__frame = frame + self.tile = self.png.im_tile + self.dispose_op = self.info.get("disposal") + self.blend_op = self.info.get("blend") + dispose_extent = self.info.get("bbox") + + if not self.tile: + msg = "image not found in APNG frame" + raise EOFError(msg) + if dispose_extent: + self.dispose_extent: tuple[float, float, float, float] = dispose_extent + + # setup frame disposal (actual disposal done when needed in the next _seek()) + if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: + self.dispose_op = Disposal.OP_BACKGROUND + + self.dispose = None + if self.dispose_op == Disposal.OP_PREVIOUS: + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) + elif self.dispose_op == Disposal.OP_BACKGROUND: + self.dispose = Image.core.fill(self.mode, self.size) + self.dispose = self._crop(self.dispose, self.dispose_extent) + + def tell(self) -> int: return self.__frame - def load_prepare(self): + def load_prepare(self) -> None: """internal: prepare to read PNG file""" if self.info.get("interlace"): @@ -870,9 +977,10 @@ def load_prepare(self): self.__idat = self.__prepare_idat # used by load_read() ImageFile.ImageFile.load_prepare(self) - def load_read(self, read_bytes): + def load_read(self, read_bytes: int) -> bytes: """internal: read more image data""" + assert self.png is not None while self.__idat == 0: # end of chunk, skip forward to next one @@ -903,8 +1011,11 @@ def load_read(self, read_bytes): return self.fp.read(read_bytes) - def load_end(self): + def load_end(self) -> None: """internal: finished reading image data""" + assert self.png is not None + if self.__idat != 0: + self.fp.read(self.__idat) while True: self.fp.read(4) # CRC @@ -928,7 +1039,13 @@ def load_end(self): except EOFError: if cid == b"fdAT": length -= 4 - ImageFile._safe_read(self.fp, length) + try: + ImageFile._safe_read(self.fp, length) + except OSError as e: + if ImageFile.LOAD_TRUNCATED_IMAGES: + break + else: + raise e except AttributeError: logger.debug("%r %s %s (unknown)", cid, pos, length) s = ImageFile._safe_read(self.fp, length) @@ -939,137 +1056,133 @@ def load_end(self): self.png.close() self.png = None else: - # setup frame disposal (actual disposal done when needed in _seek()) - if self._prev_im is None and self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: - self.dispose_op = APNG_DISPOSE_OP_BACKGROUND - - if self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: - dispose = self._prev_im.copy() - dispose = self._crop(dispose, self.dispose_extent) - elif self.dispose_op == APNG_DISPOSE_OP_BACKGROUND: - dispose = Image.core.fill(self.im.mode, self.size) - dispose = self._crop(dispose, self.dispose_extent) - else: - dispose = None - - if self._prev_im and self.blend_op == APNG_BLEND_OP_OVER: + if self._prev_im and self.blend_op == Blend.OP_OVER: updated = self._crop(self.im, self.dispose_extent) - self._prev_im.paste( - updated, self.dispose_extent, updated.convert("RGBA") - ) + if self.im.mode == "RGB" and "transparency" in self.info: + mask = updated.convert_transparent( + "RGBA", self.info["transparency"] + ) + else: + if self.im.mode == "P" and "transparency" in self.info: + t = self.info["transparency"] + if isinstance(t, bytes): + updated.putpalettealphas(t) + elif isinstance(t, int): + updated.putpalettealpha(t) + mask = updated.convert("RGBA") + self._prev_im.paste(updated, self.dispose_extent, mask) self.im = self._prev_im - if self.pyaccess: - self.pyaccess = None - self._prev_im = self.im.copy() - - if dispose: - self._prev_im.paste(dispose, self.dispose_extent) - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: if "exif" not in self.info: self.load() if "exif" not in self.info and "Raw profile type exif" not in self.info: return None - return dict(self.getexif()) + return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() return super().getexif() - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # -------------------------------------------------------------------- # PNG writer _OUTMODES = { - # supported PIL modes, and corresponding rawmodes/bits/color combinations - "1": ("1", b"\x01\x00"), - "L;1": ("L;1", b"\x01\x00"), - "L;2": ("L;2", b"\x02\x00"), - "L;4": ("L;4", b"\x04\x00"), - "L": ("L", b"\x08\x00"), - "LA": ("LA", b"\x08\x04"), - "I": ("I;16B", b"\x10\x00"), - "I;16": ("I;16B", b"\x10\x00"), - "P;1": ("P;1", b"\x01\x03"), - "P;2": ("P;2", b"\x02\x03"), - "P;4": ("P;4", b"\x04\x03"), - "P": ("P", b"\x08\x03"), - "RGB": ("RGB", b"\x08\x02"), - "RGBA": ("RGBA", b"\x08\x06"), + # supported PIL modes, and corresponding rawmode, bit depth and color type + "1": ("1", b"\x01", b"\x00"), + "L;1": ("L;1", b"\x01", b"\x00"), + "L;2": ("L;2", b"\x02", b"\x00"), + "L;4": ("L;4", b"\x04", b"\x00"), + "L": ("L", b"\x08", b"\x00"), + "LA": ("LA", b"\x08", b"\x04"), + "I": ("I;16B", b"\x10", b"\x00"), + "I;16": ("I;16B", b"\x10", b"\x00"), + "I;16B": ("I;16B", b"\x10", b"\x00"), + "P;1": ("P;1", b"\x01", b"\x03"), + "P;2": ("P;2", b"\x02", b"\x03"), + "P;4": ("P;4", b"\x04", b"\x03"), + "P": ("P", b"\x08", b"\x03"), + "RGB": ("RGB", b"\x08", b"\x02"), + "RGBA": ("RGBA", b"\x08", b"\x06"), } -def putchunk(fp, cid, *data): +def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None: """Write a PNG chunk (including CRC field)""" - data = b"".join(data) + byte_data = b"".join(data) - fp.write(o32(len(data)) + cid) - fp.write(data) - crc = _crc32(data, _crc32(cid)) + fp.write(o32(len(byte_data)) + cid) + fp.write(byte_data) + crc = _crc32(byte_data, _crc32(cid)) fp.write(o32(crc)) class _idat: # wrap output from the encoder in IDAT chunks - def __init__(self, fp, chunk): + def __init__(self, fp: IO[bytes], chunk: Callable[..., None]) -> None: self.fp = fp self.chunk = chunk - def write(self, data): + def write(self, data: bytes) -> None: self.chunk(self.fp, b"IDAT", data) class _fdat: # wrap encoder output in fdAT chunks - def __init__(self, fp, chunk, seq_num): + def __init__(self, fp: IO[bytes], chunk: Callable[..., None], seq_num: int) -> None: self.fp = fp self.chunk = chunk self.seq_num = seq_num - def write(self, data): + def write(self, data: bytes) -> None: self.chunk(self.fp, b"fdAT", o32(self.seq_num), data) self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, rawmode): - default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) - duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, + fp: IO[bytes], + chunk: Callable[..., None], + mode: str, + rawmode: str, + default_image: Image.Image | None, + append_images: list[Image.Image], +) -> Image.Image | None: + duration = im.encoderinfo.get("duration") loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) - disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - blend = im.encoderinfo.get("blend", im.info.get("blend")) + disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) + blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) if default_image: - chain = itertools.chain(im.encoderinfo.get("append_images", [])) + chain = itertools.chain(append_images) else: - chain = itertools.chain([im], im.encoderinfo.get("append_images", [])) + chain = itertools.chain([im], append_images) - im_frames = [] + im_frames: list[_Frame] = [] frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): - im_frame = im_frame.copy() - if im_frame.mode != im.mode: - if im.mode == "P": - im_frame = im_frame.convert(im.mode, palette=im.palette) - else: - im_frame = im_frame.convert(im.mode) + if im_frame.mode == mode: + im_frame = im_frame.copy() + else: + im_frame = im_frame.convert(mode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] if isinstance(disposal, (list, tuple)): encoderinfo["disposal"] = disposal[frame_count] if isinstance(blend, (list, tuple)): @@ -1078,43 +1191,42 @@ def _write_multiple_frames(im, fp, chunk, rawmode): if im_frames: previous = im_frames[-1] - prev_disposal = previous["encoderinfo"].get("disposal") - prev_blend = previous["encoderinfo"].get("blend") - if prev_disposal == APNG_DISPOSE_OP_PREVIOUS and len(im_frames) < 2: - prev_disposal = APNG_DISPOSE_OP_BACKGROUND + prev_disposal = previous.encoderinfo.get("disposal") + prev_blend = previous.encoderinfo.get("blend") + if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: + prev_disposal = Disposal.OP_BACKGROUND - if prev_disposal == APNG_DISPOSE_OP_BACKGROUND: - base_im = previous["im"] + if prev_disposal == Disposal.OP_BACKGROUND: + base_im = previous.im.copy() dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) - bbox = previous["bbox"] + bbox = previous.bbox if bbox: dispose = dispose.crop(bbox) else: bbox = (0, 0) + im.size base_im.paste(dispose, bbox) - elif prev_disposal == APNG_DISPOSE_OP_PREVIOUS: - base_im = im_frames[-2]["im"] + elif prev_disposal == Disposal.OP_PREVIOUS: + base_im = im_frames[-2].im else: - base_im = previous["im"] + base_im = previous.im delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") + im_frame.convert("RGBA"), base_im.convert("RGBA") ) - bbox = delta.getbbox() + bbox = delta.getbbox(alpha_only=False) if ( not bbox and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") + and "duration" in encoderinfo ): - duration = encoderinfo.get("duration", 0) - if duration: - if "duration" in previous["encoderinfo"]: - previous["encoderinfo"]["duration"] += duration - else: - previous["encoderinfo"]["duration"] = duration + previous.encoderinfo["duration"] += encoderinfo["duration"] continue else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + im_frames.append(_Frame(im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1 and not default_image: + return im_frames[0].im # animation control chunk( @@ -1126,20 +1238,27 @@ def _write_multiple_frames(im, fp, chunk, rawmode): # default image IDAT (if it exists) if default_image: - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + if im.mode != mode: + im = im.convert(mode) + ImageFile._save( + im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], + ) seq_num = 0 for frame, frame_data in enumerate(im_frames): - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: bbox = (0, 0) + im_frame.size else: - bbox = frame_data["bbox"] + bbox = frame_data.bbox im_frame = im_frame.crop(bbox) size = im_frame.size - duration = int(round(frame_data["encoderinfo"].get("duration", 0))) - disposal = frame_data["encoderinfo"].get("disposal", APNG_DISPOSE_OP_NONE) - blend = frame_data["encoderinfo"].get("blend", APNG_BLEND_OP_SOURCE) + encoderinfo = frame_data.encoderinfo + frame_duration = int(round(encoderinfo.get("duration", 0))) + frame_disposal = encoderinfo.get("disposal", disposal) + frame_blend = encoderinfo.get("blend", blend) # frame control chunk( fp, @@ -1149,10 +1268,10 @@ def _write_multiple_frames(im, fp, chunk, rawmode): o32(size[1]), # height o32(bbox[0]), # x_offset o32(bbox[1]), # y_offset - o16(duration), # delay_numerator + o16(frame_duration), # delay_numerator o16(1000), # delay_denominator - o8(disposal), # dispose_op - o8(blend), # blend_op + o8(frame_disposal), # dispose_op + o8(frame_blend), # blend_op ) seq_num += 1 # frame data @@ -1160,52 +1279,76 @@ def _write_multiple_frames(im, fp, chunk, rawmode): # first frame must be in IDAT chunks for backwards compatibility ImageFile._save( im_frame, - _idat(fp, chunk), - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) else: fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( im_frame, - fdat_chunks, - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], fdat_chunks), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num + return None -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) -def _save(im, fp, filename, chunk=putchunk, save_all=False): +def _save( + im: Image.Image, + fp: IO[bytes], + filename: str | bytes, + chunk: Callable[..., None] = putchunk, + save_all: bool = False, +) -> None: # save an image to disk (called by the save method) - mode = im.mode + if save_all: + default_image = im.encoderinfo.get( + "default_image", im.info.get("default_image") + ) + modes = set() + sizes = set() + append_images = im.encoderinfo.get("append_images", []) + for im_seq in itertools.chain([im], append_images): + for im_frame in ImageSequence.Iterator(im_seq): + modes.add(im_frame.mode) + sizes.add(im_frame.size) + for mode in ("RGBA", "RGB", "P"): + if mode in modes: + break + else: + mode = modes.pop() + size = tuple(max(frame_size[i] for frame_size in sizes) for i in range(2)) + else: + size = im.size + mode = im.mode + outmode = mode if mode == "P": - # # attempt to minimize storage requirements for palette images if "bits" in im.encoderinfo: # number of bits specified by user - colors = 1 << im.encoderinfo["bits"] + colors = min(1 << im.encoderinfo["bits"], 256) else: # check palette contents if im.palette: - colors = max(min(len(im.palette.getdata()[1]) // 3, 256), 2) + colors = max(min(len(im.palette.getdata()[1]) // 3, 256), 1) else: colors = 256 - if colors <= 2: - bits = 1 - elif colors <= 4: - bits = 2 - elif colors <= 16: - bits = 4 - else: - bits = 8 - if bits != 8: - mode = f"{mode};{bits}" + if colors <= 16: + if colors <= 2: + bits = 1 + elif colors <= 4: + bits = 2 + else: + bits = 4 + outmode += f";{bits}" # encoder options im.encoderconfig = ( @@ -1217,9 +1360,10 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # get the corresponding PNG mode try: - rawmode, mode = _OUTMODES[mode] + rawmode, bit_depth, color_type = _OUTMODES[outmode] except KeyError as e: - raise OSError(f"cannot write mode {mode} as PNG") from e + msg = f"cannot write mode {mode} as PNG" + raise OSError(msg) from e # # write minimal PNG file @@ -1229,9 +1373,10 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk( fp, b"IHDR", - o32(im.size[0]), # 0: size - o32(im.size[1]), - mode, # 8: depth/type + o32(size[0]), # 0: size + o32(size[1]), + bit_depth, + color_type, b"\0", # 10: compression b"\0", # 11: filter category b"\0", # 12: interlace flag @@ -1267,12 +1412,12 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, cid, data) elif cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if not after_idat: chunk(fp, cid, data) if im.mode == "P": - palette_byte_number = (2 ** bits) * 3 + palette_byte_number = colors * 3 palette_bytes = im.im.getpalette("RGB")[:palette_byte_number] while len(palette_bytes) < palette_byte_number: palette_bytes += b"\0" @@ -1283,14 +1428,14 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): if transparency or transparency == 0: if im.mode == "P": # limit to actual palette size - alpha_bytes = 2 ** bits + alpha_bytes = colors if isinstance(transparency, bytes): chunk(fp, b"tRNS", transparency[:alpha_bytes]) else: transparency = max(0, min(255, transparency)) alpha = b"\xFF" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) - elif im.mode in ("1", "L", "I"): + elif im.mode in ("1", "L", "I", "I;16"): transparency = max(0, min(65535, transparency)) chunk(fp, b"tRNS", o16(transparency)) elif im.mode == "RGB": @@ -1300,11 +1445,12 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): if "transparency" in im.encoderinfo: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. - raise OSError("cannot use transparency for this mode") + msg = "cannot use transparency for this mode" + raise OSError(msg) else: if im.mode == "P" and im.im.getpalettemode() == "RGBA": alpha = im.im.getpalette("RGBA", "A") - alpha_bytes = 2 ** bits + alpha_bytes = colors chunk(fp, b"tRNS", alpha[:alpha_bytes]) dpi = im.encoderinfo.get("dpi") @@ -1325,7 +1471,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunks.remove(cid) chunk(fp, cid, data) - exif = im.encoderinfo.get("exif", im.info.get("exif")) + exif = im.encoderinfo.get("exif") if exif: if isinstance(exif, Image.Exif): exif = exif.tobytes(8) @@ -1333,17 +1479,24 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): exif = exif[6:] chunk(fp, b"eXIf", exif) + single_im: Image.Image | None = im if save_all: - _write_multiple_frames(im, fp, chunk, rawmode) - else: - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + single_im = _write_multiple_frames( + im, fp, chunk, mode, rawmode, default_image, append_images + ) + if single_im: + ImageFile._save( + single_im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)], + ) if info: for info_chunk in info.chunks: cid, data = info_chunk[:2] if cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if after_idat: chunk(fp, cid, data) @@ -1357,32 +1510,26 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # PNG chunk converter -def getchunks(im, **params): +def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]: """Return a list of PNG chunks representing this image.""" + from io import BytesIO - class collector: - data = [] - - def write(self, data): - pass - - def append(self, chunk): - self.data.append(chunk) + chunks = [] - def append(fp, cid, *data): - data = b"".join(data) - crc = o32(_crc32(data, _crc32(cid))) - fp.append((cid, data, crc)) + def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None: + byte_data = b"".join(data) + crc = o32(_crc32(byte_data, _crc32(cid))) + chunks.append((cid, byte_data, crc)) - fp = collector() + fp = BytesIO() try: im.encoderinfo = params - _save(im, fp, None, append) + _save(im, fp, "", append) finally: del im.encoderinfo - return fp.data + return chunks # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/PpmImagePlugin.py b/dependencies/OSX/PIL/PpmImagePlugin.py index abf4d65..4e779df 100644 --- a/dependencies/OSX/PIL/PpmImagePlugin.py +++ b/dependencies/OSX/PIL/PpmImagePlugin.py @@ -13,9 +13,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import math +from typing import IO from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import o8 +from ._binary import o32le as o32 # # -------------------------------------------------------------------- @@ -24,11 +30,15 @@ MODES = { # standard + b"P1": "1", + b"P2": "L", + b"P3": "RGB", b"P4": "1", b"P5": "L", b"P6": "RGB", # extensions b"P0CMYK": "CMYK", + b"Pf": "F", # PIL extensions (for test purposes only) b"PyP": "P", b"PyRGBA": "RGBA", @@ -36,8 +46,8 @@ } -def _accept(prefix): - return prefix[0:1] == b"P" and prefix[1] in b"0456y" +def _accept(prefix: bytes) -> bool: + return prefix[0:1] == b"P" and prefix[1] in b"0123456fy" ## @@ -45,111 +55,309 @@ def _accept(prefix): class PpmImageFile(ImageFile.ImageFile): - format = "PPM" format_description = "Pbmplus image" - def _token(self, s=b""): - while True: # read until next whitespace + def _read_magic(self) -> bytes: + assert self.fp is not None + + magic = b"" + # read until whitespace or longest available magic number + for _ in range(6): c = self.fp.read(1) if not c or c in b_whitespace: break - if c > b"\x79": - raise ValueError("Expected ASCII value, found binary") - s = s + c - if len(s) > 9: - raise ValueError("Expected int, got > 9 digits") - return s - - def _open(self): - - # check magic - s = self.fp.read(1) - if s != b"P": - raise SyntaxError("not a PPM file") - magic_number = self._token(s) - mode = MODES[magic_number] - - self.custom_mimetype = { - b"P4": "image/x-portable-bitmap", - b"P5": "image/x-portable-graymap", - b"P6": "image/x-portable-pixmap", - }.get(magic_number) + magic += c + return magic + + def _read_token(self) -> bytes: + assert self.fp is not None + + token = b"" + while len(token) <= 10: # read until next whitespace or limit of 10 characters + c = self.fp.read(1) + if not c: + break + elif c in b_whitespace: # token ended + if not token: + # skip whitespace at start + continue + break + elif c == b"#": + # ignores rest of the line; stops at CR, LF or EOF + while self.fp.read(1) not in b"\r\n": + pass + continue + token += c + if not token: + # Token was not even 1 byte + msg = "Reached EOF while reading header" + raise ValueError(msg) + elif len(token) > 10: + msg = f"Token too long in file header: {token.decode()}" + raise ValueError(msg) + return token + def _open(self) -> None: + assert self.fp is not None + + magic_number = self._read_magic() + try: + mode = MODES[magic_number] + except KeyError: + msg = "not a PPM file" + raise SyntaxError(msg) + self._mode = mode + + if magic_number in (b"P1", b"P4"): + self.custom_mimetype = "image/x-portable-bitmap" + elif magic_number in (b"P2", b"P5"): + self.custom_mimetype = "image/x-portable-graymap" + elif magic_number in (b"P3", b"P6"): + self.custom_mimetype = "image/x-portable-pixmap" + + self._size = int(self._read_token()), int(self._read_token()) + + decoder_name = "raw" + if magic_number in (b"P1", b"P2", b"P3"): + decoder_name = "ppm_plain" + + args: str | tuple[str | int, ...] if mode == "1": - self.mode = "1" - rawmode = "1;I" + args = "1;I" + elif mode == "F": + scale = float(self._read_token()) + if scale == 0.0 or not math.isfinite(scale): + msg = "scale must be finite and non-zero" + raise ValueError(msg) + self.info["scale"] = abs(scale) + + rawmode = "F;32F" if scale < 0 else "F;32BF" + args = (rawmode, 0, -1) else: - self.mode = rawmode = mode - - for ix in range(3): - while True: - while True: - s = self.fp.read(1) - if s not in b_whitespace: - break - if s == b"": - raise ValueError("File does not extend beyond magic number") - if s != b"#": + maxval = int(self._read_token()) + if not 0 < maxval < 65536: + msg = "maxval must be greater than 0 and less than 65536" + raise ValueError(msg) + if maxval > 255 and mode == "L": + self._mode = "I" + + rawmode = mode + if decoder_name != "ppm_plain": + # If maxval matches a bit depth, use the raw decoder directly + if maxval == 65535 and mode == "L": + rawmode = "I;16B" + elif maxval != 255: + decoder_name = "ppm" + + args = rawmode if decoder_name == "raw" else (rawmode, maxval) + self.tile = [ + ImageFile._Tile(decoder_name, (0, 0) + self.size, self.fp.tell(), args) + ] + + +# +# -------------------------------------------------------------------- + + +class PpmPlainDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _comment_spans: bool + + def _read_block(self) -> bytes: + assert self.fd is not None + + return self.fd.read(ImageFile.SAFEBLOCK) + + def _find_comment_end(self, block: bytes, start: int = 0) -> int: + a = block.find(b"\n", start) + b = block.find(b"\r", start) + return min(a, b) if a * b > 0 else max(a, b) # lowest nonnegative index (or -1) + + def _ignore_comments(self, block: bytes) -> bytes: + if self._comment_spans: + # Finish current comment + while block: + comment_end = self._find_comment_end(block) + if comment_end != -1: + # Comment ends in this block + # Delete tail of comment + block = block[comment_end + 1 :] break - s = self.fp.readline() - s = int(self._token(s)) - if ix == 0: - xsize = s - elif ix == 1: - ysize = s - if mode == "1": + else: + # Comment spans whole block + # So read the next block, looking for the end + block = self._read_block() + + # Search for any further comments + self._comment_spans = False + while True: + comment_start = block.find(b"#") + if comment_start == -1: + # No comment found + break + comment_end = self._find_comment_end(block, comment_start) + if comment_end != -1: + # Comment ends in this block + # Delete comment + block = block[:comment_start] + block[comment_end + 1 :] + else: + # Comment continues to next block(s) + block = block[:comment_start] + self._comment_spans = True + break + return block + + def _decode_bitonal(self) -> bytearray: + """ + This is a separate method because in the plain PBM format, all data tokens are + exactly one byte, so the inter-token whitespace is optional. + """ + data = bytearray() + total_bytes = self.state.xsize * self.state.ysize + + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + # eof + break + + block = self._ignore_comments(block) + + tokens = b"".join(block.split()) + for token in tokens: + if token not in (48, 49): + msg = b"Invalid token for this mode: %s" % bytes([token]) + raise ValueError(msg) + data = (data + tokens)[:total_bytes] + invert = bytes.maketrans(b"01", b"\xFF\x00") + return data.translate(invert) + + def _decode_blocks(self, maxval: int) -> bytearray: + data = bytearray() + max_len = 10 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + total_bytes = self.state.xsize * self.state.ysize * bands * out_byte_count + + half_token = b"" + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + if half_token: + block = bytearray(b" ") # flush half_token + else: + # eof + break + + block = self._ignore_comments(block) + + if half_token: + block = half_token + block # stitch half_token to new block + half_token = b"" + + tokens = block.split() + + if block and not block[-1:].isspace(): # block might split token + half_token = tokens.pop() # save half token for later + if len(half_token) > max_len: # prevent buildup of half_token + msg = ( + b"Token too long found in data: %s" % half_token[: max_len + 1] + ) + raise ValueError(msg) + + for token in tokens: + if len(token) > max_len: + msg = b"Token too long found in data: %s" % token[: max_len + 1] + raise ValueError(msg) + value = int(token) + if value < 0: + msg_str = f"Channel value is negative: {value}" + raise ValueError(msg_str) + if value > maxval: + msg_str = f"Channel value too large for this mode: {value}" + raise ValueError(msg_str) + value = round(value / maxval * out_max) + data += o32(value) if self.mode == "I" else o8(value) + if len(data) == total_bytes: # finished! break - elif ix == 2: - # maxgrey - if s > 255: - if not mode == "L": - raise ValueError(f"Too many colors for band: {s}") - if s < 2 ** 16: - self.mode = "I" - rawmode = "I;16B" - else: - self.mode = "I" - rawmode = "I;32B" - - self._size = xsize, ysize - self.tile = [("raw", (0, 0, xsize, ysize), self.fp.tell(), (rawmode, 0, 1))] + return data + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + self._comment_spans = False + if self.mode == "1": + data = self._decode_bitonal() + rawmode = "1;8" + else: + maxval = self.args[-1] + data = self._decode_blocks(maxval) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 + + +class PpmDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + data = bytearray() + maxval = self.args[-1] + in_byte_count = 1 if maxval < 256 else 2 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands * out_byte_count + while len(data) < dest_length: + pixels = self.fd.read(in_byte_count * bands) + if len(pixels) < in_byte_count * bands: + # eof + break + for b in range(bands): + value = ( + pixels[b] if in_byte_count == 1 else i16(pixels, b * in_byte_count) + ) + value = min(out_max, round(value / maxval * out_max)) + data += o32(value) if self.mode == "I" else o8(value) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 # # -------------------------------------------------------------------- -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": rawmode, head = "L", b"P5" - elif im.mode == "I": - if im.getextrema()[1] < 2 ** 16: - rawmode, head = "I;16B", b"P5" - else: - rawmode, head = "I;32B", b"P5" - elif im.mode == "RGB": - rawmode, head = "RGB", b"P6" - elif im.mode == "RGBA": + elif im.mode in ("I", "I;16"): + rawmode, head = "I;16B", b"P5" + elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" + elif im.mode == "F": + rawmode, head = "F;32F", b"Pf" else: - raise OSError(f"cannot write mode {im.mode} as PPM") - fp.write(head + ("\n%d %d\n" % im.size).encode("ascii")) + msg = f"cannot write mode {im.mode} as PPM" + raise OSError(msg) + fp.write(head + b"\n%d %d\n" % im.size) if head == b"P6": fp.write(b"255\n") - if head == b"P5": + elif head == b"P5": if rawmode == "L": fp.write(b"255\n") - elif rawmode == "I;16B": + else: fp.write(b"65535\n") - elif rawmode == "I;32B": - fp.write(b"2147483648\n") - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) - - # ALTERNATIVE: save via builtin debug function - # im._dump(filename) + elif head == b"Pf": + fp.write(b"-1.0\n") + row_order = -1 if im.mode == "F" else 1 + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))] + ) # @@ -159,6 +367,9 @@ def _save(im, fp, filename): Image.register_open(PpmImageFile.format, PpmImageFile, _accept) Image.register_save(PpmImageFile.format, _save) -Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"]) +Image.register_decoder("ppm", PpmDecoder) +Image.register_decoder("ppm_plain", PpmPlainDecoder) + +Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"]) Image.register_mime(PpmImageFile.format, "image/x-portable-anymap") diff --git a/dependencies/OSX/PIL/PsdImagePlugin.py b/dependencies/OSX/PIL/PsdImagePlugin.py index 8d1dbf2..8ff5e39 100644 --- a/dependencies/OSX/PIL/PsdImagePlugin.py +++ b/dependencies/OSX/PIL/PsdImagePlugin.py @@ -15,13 +15,18 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io +from functools import cached_property +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._binary import si16be as si16 +from ._binary import si32be as si32 MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -41,7 +46,7 @@ # read PSD images -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"8BPS" @@ -50,33 +55,36 @@ def _accept(prefix): class PsdImageFile(ImageFile.ImageFile): - format = "PSD" format_description = "Adobe Photoshop" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: read = self.fp.read # # header s = read(26) - if not _accept(s) or i16(s[4:]) != 1: - raise SyntaxError("not a PSD file") + if not _accept(s) or i16(s, 4) != 1: + msg = "not a PSD file" + raise SyntaxError(msg) - psd_bits = i16(s[22:]) - psd_channels = i16(s[12:]) - psd_mode = i16(s[24:]) + psd_bits = i16(s, 22) + psd_channels = i16(s, 12) + psd_mode = i16(s, 24) mode, channels = MODES[(psd_mode, psd_bits)] if channels > psd_channels: - raise OSError("not enough channels") + msg = "not enough channels" + raise OSError(msg) + if mode == "RGB" and psd_channels == 4: + mode = "RGBA" + channels = 4 - self.mode = mode - self._size = i32(s[18:]), i32(s[14:]) + self._mode = mode + self._size = i32(s, 18), i32(s, 14) # # color mode data @@ -112,17 +120,17 @@ def _open(self): # # layer and mask information - self.layers = [] + self._layers_position = None size = i32(read(4)) if size: end = self.fp.tell() + size size = i32(read(4)) if size: - self.layers = _layerinfo(self.fp) + self._layers_position = self.fp.tell() + self._layers_size = size self.fp.seek(end) - self.n_frames = len(self.layers) - self.is_animated = self.n_frames > 1 + self._n_frames: int | None = None # # image descriptor @@ -130,134 +138,147 @@ def _open(self): self.tile = _maketile(self.fp, mode, (0, 0) + self.size, channels) # keep the file open - self.__fp = self.fp + self._fp = self.fp self.frame = 1 self._min_frame = 1 - def seek(self, layer): + @cached_property + def layers( + self, + ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: + layers = [] + if self._layers_position is not None: + self._fp.seek(self._layers_position) + _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) + layers = _layerinfo(_layer_data, self._layers_size) + self._n_frames = len(layers) + return layers + + @property + def n_frames(self) -> int: + if self._n_frames is None: + self._n_frames = len(self.layers) + return self._n_frames + + @property + def is_animated(self) -> bool: + return len(self.layers) > 1 + + def seek(self, layer: int) -> None: if not self._seek_check(layer): return # seek to given layer (1..max) try: - name, mode, bbox, tile = self.layers[layer - 1] - self.mode = mode + _, mode, _, tile = self.layers[layer - 1] + self._mode = mode self.tile = tile self.frame = layer - self.fp = self.__fp - return name, bbox + self.fp = self._fp except IndexError as e: - raise EOFError("no such layer") from e + msg = "no such layer" + raise EOFError(msg) from e - def tell(self): + def tell(self) -> int: # return layer number (0=image, 1..max=layers) return self.frame - def load_prepare(self): - # create image memory if necessary - if not self.im or self.im.mode != self.mode or self.im.size != self.size: - self.im = Image.core.fill(self.mode, self.size, 0) - # create palette (optional) - if self.mode == "P": - Image.Image.load(self) - - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - -def _layerinfo(file): +def _layerinfo( + fp: IO[bytes], ct_bytes: int +) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: # read layerinfo block layers = [] - read = file.read - for i in range(abs(i16(read(2)))): + def read(size: int) -> bytes: + return ImageFile._safe_read(fp, size) + + ct = si16(read(2)) + + # sanity check + if ct_bytes < (abs(ct) * 20): + msg = "Layer block too short for number of layers requested" + raise SyntaxError(msg) + + for _ in range(abs(ct)): # bounding box - y0 = i32(read(4)) - x0 = i32(read(4)) - y1 = i32(read(4)) - x1 = i32(read(4)) + y0 = si32(read(4)) + x0 = si32(read(4)) + y1 = si32(read(4)) + x1 = si32(read(4)) # image info - info = [] - mode = [] - types = list(range(i16(read(2)))) - if len(types) > 4: + bands = [] + ct_types = i16(read(2)) + if ct_types > 4: + fp.seek(ct_types * 6 + 12, io.SEEK_CUR) + size = i32(read(4)) + fp.seek(size, io.SEEK_CUR) continue - for i in types: + for _ in range(ct_types): type = i16(read(2)) if type == 65535: - m = "A" + b = "A" else: - m = "RGBA"[type] + b = "RGBA"[type] - mode.append(m) - size = i32(read(4)) - info.append((m, size)) + bands.append(b) + read(4) # size # figure out the image mode - mode.sort() - if mode == ["R"]: + bands.sort() + if bands == ["R"]: mode = "L" - elif mode == ["B", "G", "R"]: + elif bands == ["B", "G", "R"]: mode = "RGB" - elif mode == ["A", "B", "G", "R"]: + elif bands == ["A", "B", "G", "R"]: mode = "RGBA" else: - mode = None # unknown + mode = "" # unknown # skip over blend flags and extra information read(12) # filler name = "" size = i32(read(4)) # length of the extra data field - combined = 0 if size: - data_end = file.tell() + size + data_end = fp.tell() + size length = i32(read(4)) if length: - file.seek(length - 16, io.SEEK_CUR) - combined += length + 4 + fp.seek(length - 16, io.SEEK_CUR) length = i32(read(4)) if length: - file.seek(length, io.SEEK_CUR) - combined += length + 4 + fp.seek(length, io.SEEK_CUR) length = i8(read(1)) if length: # Don't know the proper encoding, # Latin-1 should be a good guess name = read(length).decode("latin-1", "replace") - combined += length + 1 - file.seek(data_end) + fp.seek(data_end) layers.append((name, mode, (x0, y0, x1, y1))) # get tiles - i = 0 - for name, mode, bbox in layers: + layerinfo = [] + for i, (name, mode, bbox) in enumerate(layers): tile = [] for m in mode: - t = _maketile(file, m, bbox, 1) + t = _maketile(fp, m, bbox, 1) if t: tile.extend(t) - layers[i] = name, mode, bbox, tile - i += 1 + layerinfo.append((name, mode, bbox, tile)) - return layers + return layerinfo -def _maketile(file, mode, bbox, channels): - - tile = None +def _maketile( + file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int +) -> list[ImageFile._Tile]: + tiles = [] read = file.read compression = i16(read(2)) @@ -270,28 +291,26 @@ def _maketile(file, mode, bbox, channels): if compression == 0: # # raw compression - tile = [] for channel in range(channels): layer = mode[channel] if mode == "CMYK": layer += ";I" - tile.append(("raw", bbox, offset, layer)) + tiles.append(ImageFile._Tile("raw", bbox, offset, layer)) offset = offset + xsize * ysize elif compression == 1: # # packbits compression i = 0 - tile = [] bytecount = read(channels * ysize * 2) offset = file.tell() for channel in range(channels): layer = mode[channel] if mode == "CMYK": layer += ";I" - tile.append(("packbits", bbox, offset, layer)) + tiles.append(ImageFile._Tile("packbits", bbox, offset, layer)) for y in range(ysize): - offset = offset + i16(bytecount[i : i + 2]) + offset = offset + i16(bytecount, i) i += 2 file.seek(offset) @@ -299,7 +318,7 @@ def _maketile(file, mode, bbox, channels): if offset & 1: read(1) # padding - return tile + return tiles # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/PyAccess.py b/dependencies/OSX/PIL/PyAccess.py deleted file mode 100644 index 494f5f9..0000000 --- a/dependencies/OSX/PIL/PyAccess.py +++ /dev/null @@ -1,352 +0,0 @@ -# -# The Python Imaging Library -# Pillow fork -# -# Python implementation of the PixelAccess Object -# -# Copyright (c) 1997-2009 by Secret Labs AB. All rights reserved. -# Copyright (c) 1995-2009 by Fredrik Lundh. -# Copyright (c) 2013 Eric Soroos -# -# See the README file for information on usage and redistribution -# - -# Notes: -# -# * Implements the pixel access object following Access. -# * Does not implement the line functions, as they don't appear to be used -# * Taking only the tuple form, which is used from python. -# * Fill.c uses the integer form, but it's still going to use the old -# Access.c implementation. -# - -import logging -import sys - -try: - from cffi import FFI - - defs = """ - struct Pixel_RGBA { - unsigned char r,g,b,a; - }; - struct Pixel_I16 { - unsigned char l,r; - }; - """ - ffi = FFI() - ffi.cdef(defs) -except ImportError as ex: - # Allow error import for doc purposes, but error out when accessing - # anything in core. - from ._util import deferred_error - - FFI = ffi = deferred_error(ex) - -logger = logging.getLogger(__name__) - - -class PyAccess: - def __init__(self, img, readonly=False): - vals = dict(img.im.unsafe_ptrs) - self.readonly = readonly - self.image8 = ffi.cast("unsigned char **", vals["image8"]) - self.image32 = ffi.cast("int **", vals["image32"]) - self.image = ffi.cast("unsigned char **", vals["image"]) - self.xsize, self.ysize = img.im.size - - # Keep pointer to im object to prevent dereferencing. - self._im = img.im - if self._im.mode == "P": - self._palette = img.palette - - # Debugging is polluting test traces, only useful here - # when hacking on PyAccess - # logger.debug("%s", vals) - self._post_init() - - def _post_init(self): - pass - - def __setitem__(self, xy, color): - """ - Modifies the pixel at x,y. The color is given as a single - numerical value for single band images, and a tuple for - multi-band images - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :param color: The pixel value. - """ - if self.readonly: - raise ValueError("Attempt to putpixel a read only image") - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - - if ( - self._im.mode == "P" - and isinstance(color, (list, tuple)) - and len(color) in [3, 4] - ): - # RGB or RGBA value for a P image - color = self._palette.getcolor(color) - - return self.set_pixel(x, y, color) - - def __getitem__(self, xy): - """ - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multiple band - images - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. - """ - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - return self.get_pixel(x, y) - - putpixel = __setitem__ - getpixel = __getitem__ - - def check_xy(self, xy): - (x, y) = xy - if not (0 <= x < self.xsize and 0 <= y < self.ysize): - raise ValueError("pixel location out of range") - return xy - - -class _PyAccess32_2(PyAccess): - """ PA, LA, stored in first and last bytes of a 32 bit word """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.a) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.a = min(color[1], 255) - - -class _PyAccess32_3(PyAccess): - """ RGB and friends, stored in the first three bytes of a 32 bit word """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.g, pixel.b) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = 255 - - -class _PyAccess32_4(PyAccess): - """ RGBA etc, all 4 bytes of a 32 bit word """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.g, pixel.b, pixel.a) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = min(color[3], 255) - - -class _PyAccess8(PyAccess): - """ 1, L, P, 8 bit images stored as uint8 """ - - def _post_init(self, *args, **kwargs): - self.pixels = self.image8 - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 255) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 255) - - -class _PyAccessI16_N(PyAccess): - """ I;16 access, native bitendian without conversion """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("unsigned short **", self.image) - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 65535) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 65535) - - -class _PyAccessI16_L(PyAccess): - """ I;16L access, with conversion """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return pixel.l + pixel.r * 256 - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except TypeError: - color = min(color[0], 65535) - - pixel.l = color & 0xFF # noqa: E741 - pixel.r = color >> 8 - - -class _PyAccessI16_B(PyAccess): - """ I;16B access, with conversion """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return pixel.l * 256 + pixel.r - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except Exception: - color = min(color[0], 65535) - - pixel.l = color >> 8 # noqa: E741 - pixel.r = color & 0xFF - - -class _PyAccessI32_N(PyAccess): - """ Signed Int32 access, native endian """ - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - self.pixels[y][x] = color - - -class _PyAccessI32_Swap(PyAccess): - """ I;32L/B access, with byteswapping conversion """ - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def reverse(self, i): - orig = ffi.new("int *", i) - chars = ffi.cast("unsigned char *", orig) - chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0] - return ffi.cast("int *", chars)[0] - - def get_pixel(self, x, y): - return self.reverse(self.pixels[y][x]) - - def set_pixel(self, x, y, color): - self.pixels[y][x] = self.reverse(color) - - -class _PyAccessF(PyAccess): - """ 32 bit float access """ - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("float **", self.image32) - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # not a tuple - self.pixels[y][x] = color - except TypeError: - # tuple - self.pixels[y][x] = color[0] - - -mode_map = { - "1": _PyAccess8, - "L": _PyAccess8, - "P": _PyAccess8, - "LA": _PyAccess32_2, - "La": _PyAccess32_2, - "PA": _PyAccess32_2, - "RGB": _PyAccess32_3, - "LAB": _PyAccess32_3, - "HSV": _PyAccess32_3, - "YCbCr": _PyAccess32_3, - "RGBA": _PyAccess32_4, - "RGBa": _PyAccess32_4, - "RGBX": _PyAccess32_4, - "CMYK": _PyAccess32_4, - "F": _PyAccessF, - "I": _PyAccessI32_N, -} - -if sys.byteorder == "little": - mode_map["I;16"] = _PyAccessI16_N - mode_map["I;16L"] = _PyAccessI16_N - mode_map["I;16B"] = _PyAccessI16_B - - mode_map["I;32L"] = _PyAccessI32_N - mode_map["I;32B"] = _PyAccessI32_Swap -else: - mode_map["I;16"] = _PyAccessI16_L - mode_map["I;16L"] = _PyAccessI16_L - mode_map["I;16B"] = _PyAccessI16_N - - mode_map["I;32L"] = _PyAccessI32_Swap - mode_map["I;32B"] = _PyAccessI32_N - - -def new(img, readonly=False): - access_type = mode_map.get(img.mode, None) - if not access_type: - logger.debug("PyAccess Not Implemented: %s", img.mode) - return None - return access_type(img, readonly) diff --git a/dependencies/OSX/PIL/QoiImagePlugin.py b/dependencies/OSX/PIL/QoiImagePlugin.py new file mode 100644 index 0000000..01cc868 --- /dev/null +++ b/dependencies/OSX/PIL/QoiImagePlugin.py @@ -0,0 +1,115 @@ +# +# The Python Imaging Library. +# +# QOI support for PIL +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os + +from . import Image, ImageFile +from ._binary import i32be as i32 + + +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"qoif" + + +class QoiImageFile(ImageFile.ImageFile): + format = "QOI" + format_description = "Quite OK Image" + + def _open(self) -> None: + if not _accept(self.fp.read(4)): + msg = "not a QOI file" + raise SyntaxError(msg) + + self._size = i32(self.fp.read(4)), i32(self.fp.read(4)) + + channels = self.fp.read(1)[0] + self._mode = "RGB" if channels == 3 else "RGBA" + + self.fp.seek(1, os.SEEK_CUR) # colorspace + self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell())] + + +class QoiDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} + + def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: + self._previous_pixel = value + + r, g, b, a = value + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + self._previously_seen_pixels[hash_value] = value + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + self._previously_seen_pixels = {} + self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) + + data = bytearray() + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands + while len(data) < dest_length: + byte = self.fd.read(1)[0] + value: bytes | bytearray + if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB + value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] + elif byte == 0b11111111: # QOI_OP_RGBA + value = self.fd.read(4) + else: + op = byte >> 6 + if op == 0: # QOI_OP_INDEX + op_index = byte & 0b00111111 + value = self._previously_seen_pixels.get( + op_index, bytearray((0, 0, 0, 0)) + ) + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF + value = bytearray( + ( + (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) + % 256, + (self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2) + % 256, + (self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256, + self._previous_pixel[3], + ) + ) + elif op == 2 and self._previous_pixel: # QOI_OP_LUMA + second_byte = self.fd.read(1)[0] + diff_green = (byte & 0b00111111) - 32 + diff_red = ((second_byte & 0b11110000) >> 4) - 8 + diff_blue = (second_byte & 0b00001111) - 8 + + value = bytearray( + tuple( + (self._previous_pixel[i] + diff_green + diff) % 256 + for i, diff in enumerate((diff_red, 0, diff_blue)) + ) + ) + value += self._previous_pixel[3:] + elif op == 3 and self._previous_pixel: # QOI_OP_RUN + run_length = (byte & 0b00111111) + 1 + value = self._previous_pixel + if bands == 3: + value = value[:3] + data += value * run_length + continue + self._add_to_previous_pixels(value) + + if bands == 3: + value = value[:3] + data += value + self.set_as_raw(data) + return -1, 0 + + +Image.register_open(QoiImageFile.format, QoiImageFile, _accept) +Image.register_decoder("qoi", QoiDecoder) +Image.register_extension(QoiImageFile.format, ".qoi") diff --git a/dependencies/OSX/PIL/SgiImagePlugin.py b/dependencies/OSX/PIL/SgiImagePlugin.py index f878fef..44254b7 100644 --- a/dependencies/OSX/PIL/SgiImagePlugin.py +++ b/dependencies/OSX/PIL/SgiImagePlugin.py @@ -20,18 +20,18 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import struct +from typing import IO from . import Image, ImageFile -from ._binary import i8 from ._binary import i16be as i16 from ._binary import o8 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 2 and i16(prefix) == 474 @@ -50,36 +50,37 @@ def _accept(prefix): ## # Image plugin for SGI images. class SgiImageFile(ImageFile.ImageFile): - format = "SGI" format_description = "SGI Image File Format" - def _open(self): - + def _open(self) -> None: # HEAD + assert self.fp is not None + headlen = 512 s = self.fp.read(headlen) if not _accept(s): - raise ValueError("Not an SGI image file") + msg = "Not an SGI image file" + raise ValueError(msg) # compression : verbatim or RLE - compression = i8(s[2]) + compression = s[2] # bpc : 1 or 2 bytes (8bits or 16bits) - bpc = i8(s[3]) + bpc = s[3] # dimension : 1, 2 or 3 (depending on xsize, ysize and zsize) - dimension = i16(s[4:]) + dimension = i16(s, 4) # xsize : width - xsize = i16(s[6:]) + xsize = i16(s, 6) # ysize : height - ysize = i16(s[8:]) + ysize = i16(s, 8) # zsize : channels count - zsize = i16(s[10:]) + zsize = i16(s, 10) # layout layout = bpc, dimension, zsize @@ -92,10 +93,11 @@ def _open(self): pass if rawmode == "": - raise ValueError("Unsupported SGI image mode") + msg = "Unsupported SGI image mode" + raise ValueError(msg) self._size = xsize, ysize - self.mode = rawmode.split(";")[0] + self._mode = rawmode.split(";")[0] if self.mode == "RGB": self.custom_mimetype = "image/rgb" @@ -107,25 +109,35 @@ def _open(self): pagesize = xsize * ysize * bpc if bpc == 2: self.tile = [ - ("SGI16", (0, 0) + self.size, headlen, (self.mode, 0, orientation)) + ImageFile._Tile( + "SGI16", + (0, 0) + self.size, + headlen, + (self.mode, 0, orientation), + ) ] else: self.tile = [] offset = headlen for layer in self.mode: self.tile.append( - ("raw", (0, 0) + self.size, offset, (layer, 0, orientation)) + ImageFile._Tile( + "raw", (0, 0) + self.size, offset, (layer, 0, orientation) + ) ) offset += pagesize elif compression == 1: self.tile = [ - ("sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc)) + ImageFile._Tile( + "sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc) + ) ] -def _save(im, fp, filename): - if im.mode != "RGB" and im.mode != "RGBA" and im.mode != "L": - raise ValueError("Unsupported SGI image mode") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in {"RGB", "RGBA", "L"}: + msg = "Unsupported SGI image mode" + raise ValueError(msg) # Get the keyword arguments info = im.encoderinfo @@ -134,12 +146,13 @@ def _save(im, fp, filename): bpc = info.get("bpc", 1) if bpc not in (1, 2): - raise ValueError("Unsupported number of bytes per pixel") + msg = "Unsupported number of bytes per pixel" + raise ValueError(msg) # Flip the image, since the origin of SGI file is the bottom-left corner orientation = -1 # Define the file as SGI File Format - magicNumber = 474 + magic_number = 474 # Run-Length Encoding Compression - Unsupported at this time rle = 0 @@ -154,25 +167,25 @@ def _save(im, fp, filename): # Z Dimension: Number of channels z = len(im.mode) - if dim == 1 or dim == 2: + if dim in {1, 2}: z = 1 # assert we've got the right number of bands. if len(im.getbands()) != z: - raise ValueError( - f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" - ) + msg = f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" + raise ValueError(msg) # Minimum Byte value pinmin = 0 # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - imgName = os.path.splitext(os.path.basename(filename))[0] - imgName = imgName.encode("ascii", "ignore") + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 - fp.write(struct.pack(">h", magicNumber)) + fp.write(struct.pack(">h", magic_number)) fp.write(o8(rle)) fp.write(o8(bpc)) fp.write(struct.pack(">H", dim)) @@ -182,8 +195,8 @@ def _save(im, fp, filename): fp.write(struct.pack(">l", pinmin)) fp.write(struct.pack(">l", pinmax)) fp.write(struct.pack("4s", b"")) # dummy - fp.write(struct.pack("79s", imgName)) # truncates to 79 chars - fp.write(struct.pack("s", b"")) # force null byte after imgname + fp.write(struct.pack("79s", img_name)) # truncates to 79 chars + fp.write(struct.pack("s", b"")) # force null byte after img_name fp.write(struct.pack(">l", colormap)) fp.write(struct.pack("404s", b"")) # dummy @@ -194,13 +207,17 @@ def _save(im, fp, filename): for channel in im.split(): fp.write(channel.tobytes("raw", rawmode, 0, orientation)) - fp.close() + if hasattr(fp, "flush"): + fp.flush() class SGI16Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + assert self.im is not None + rawmode, stride, orientation = self.args pagesize = self.state.xsize * self.state.ysize zsize = len(self.mode) diff --git a/dependencies/OSX/PIL/SpiderImagePlugin.py b/dependencies/OSX/PIL/SpiderImagePlugin.py index 819f2ed..3a87d00 100644 --- a/dependencies/OSX/PIL/SpiderImagePlugin.py +++ b/dependencies/OSX/PIL/SpiderImagePlugin.py @@ -15,7 +15,7 @@ # ## -# Image plugin for the Spider image format. This format is is used +# Image plugin for the Spider image format. This format is used # by the SPIDER software, in processing image data from electron # microscopy and tomography. ## @@ -32,14 +32,17 @@ # Details about the Spider image format: # https://spider.wadsworth.org/spider_doc/spider/docs/image_doc.html # +from __future__ import annotations + import os import struct import sys +from typing import IO, TYPE_CHECKING, Any, cast -from PIL import Image, ImageFile +from . import Image, ImageFile -def isInt(f): +def isInt(f: Any) -> int: try: i = int(f) if f - i == 0: @@ -59,7 +62,7 @@ def isInt(f): # otherwise returns 0 -def isSpiderHeader(t): +def isSpiderHeader(t: tuple[float, ...]) -> int: h = (99,) + t # add 1 value so can use spider header index start=1 # header values 1,2,5,12,13,22,23 should be integers for i in [1, 2, 5, 12, 13, 22, 23]: @@ -79,7 +82,7 @@ def isSpiderHeader(t): return labbyt -def isSpiderImage(filename): +def isSpiderImage(filename: str) -> int: with open(filename, "rb") as fp: f = fp.read(92) # read 23 * 4 bytes t = struct.unpack(">23f", f) # try big-endian first @@ -91,12 +94,11 @@ def isSpiderImage(filename): class SpiderImageFile(ImageFile.ImageFile): - format = "SPIDER" format_description = "Spider 2D image" _close_exclusive_fp_after_loading = False - def _open(self): + def _open(self) -> None: # check header n = 27 * 4 # read 27 float values f = self.fp.read(n) @@ -110,14 +112,17 @@ def _open(self): t = struct.unpack("<27f", f) # little-endian hdrlen = isSpiderHeader(t) if hdrlen == 0: - raise SyntaxError("not a valid Spider file") + msg = "not a valid Spider file" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError("not a valid Spider file") from e + msg = "not a valid Spider file" + raise SyntaxError(msg) from e h = (99,) + t # add 1 value : spider header index starts at 1 iform = int(h[5]) if iform != 1: - raise SyntaxError("not a Spider 2D image") + msg = "not a Spider 2D image" + raise SyntaxError(msg) self._size = int(h[12]), int(h[2]) # size in pixels (width, height) self.istack = int(h[24]) @@ -140,115 +145,114 @@ def _open(self): offset = hdrlen + self.stkoffset self.istack = 2 # So Image knows it's still a stack else: - raise SyntaxError("inconsistent stack header values") + msg = "inconsistent stack header values" + raise SyntaxError(msg) if self.bigendian: self.rawmode = "F;32BF" else: self.rawmode = "F;32F" - self.mode = "F" + self._mode = "F" - self.tile = [("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1))] - self.__fp = self.fp # FIXME: hack + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, offset, self.rawmode)] + self._fp = self.fp # FIXME: hack @property - def n_frames(self): + def n_frames(self) -> int: return self._nimages @property - def is_animated(self): + def is_animated(self) -> bool: return self._nimages > 1 # 1st image index is zero (although SPIDER imgnumber starts at 1) - def tell(self): + def tell(self) -> int: if self.imgnumber < 1: return 0 else: return self.imgnumber - 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if self.istack == 0: - raise EOFError("attempt to seek in a non-stack file") + msg = "attempt to seek in a non-stack file" + raise EOFError(msg) if not self._seek_check(frame): return self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self.stkoffset) self._open() # returns a byte image after rescaling to 0..255 - def convert2byte(self, depth=255): - (minimum, maximum) = self.getextrema() - m = 1 + def convert2byte(self, depth: int = 255) -> Image.Image: + extrema = self.getextrema() + assert isinstance(extrema[0], float) + minimum, maximum = cast(tuple[float, float], extrema) + m: float = 1 if maximum != minimum: m = depth / (maximum - minimum) b = -m * minimum - return self.point(lambda i, m=m, b=b: i * m + b).convert("L") + return self.point(lambda i: i * m + b).convert("L") + + if TYPE_CHECKING: + from . import ImageTk # returns a ImageTk.PhotoImage object, after rescaling to 0..255 - def tkPhotoImage(self): - from PIL import ImageTk + def tkPhotoImage(self) -> ImageTk.PhotoImage: + from . import ImageTk return ImageTk.PhotoImage(self.convert2byte(), palette=256) - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # -------------------------------------------------------------------- # Image series + # given a list of filenames, return a list of images -def loadImageSeries(filelist=None): +def loadImageSeries(filelist: list[str] | None = None) -> list[Image.Image] | None: """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" if filelist is None or len(filelist) < 1: - return + return None - imglist = [] + byte_imgs = [] for img in filelist: if not os.path.exists(img): print(f"unable to find {img}") continue try: with Image.open(img) as im: - im = im.convert2byte() + assert isinstance(im, SpiderImageFile) + byte_im = im.convert2byte() except Exception: if not isSpiderImage(img): - print(img + " is not a Spider image file") + print(f"{img} is not a Spider image file") continue - im.info["filename"] = img - imglist.append(im) - return imglist + byte_im.info["filename"] = img + byte_imgs.append(byte_im) + return byte_imgs # -------------------------------------------------------------------- # For saving images in Spider format -def makeSpiderHeader(im): +def makeSpiderHeader(im: Image.Image) -> list[bytes]: nsam, nrow = im.size lenbyt = nsam * 4 # There are labrec records in the header labrec = int(1024 / lenbyt) if 1024 % lenbyt != 0: labrec += 1 labbyt = labrec * lenbyt - hdr = [] nvalues = int(labbyt / 4) - for i in range(nvalues): - hdr.append(0.0) - - if len(hdr) < 23: + if nvalues < 23: return [] + hdr = [0.0] * nvalues + # NB these are Fortran indices hdr[1] = 1.0 # nslice (=1 for an image) hdr[2] = float(nrow) # number of rows per slice + hdr[3] = float(nrow) # number of records in the image hdr[5] = 1.0 # iform for 2D image hdr[12] = float(nsam) # number of pixels per line hdr[13] = float(labrec) # number of records in file header @@ -259,30 +263,29 @@ def makeSpiderHeader(im): hdr = hdr[1:] hdr.append(0.0) # pack binary data into a string - hdrstr = [] - for v in hdr: - hdrstr.append(struct.pack("f", v)) - return hdrstr + return [struct.pack("f", v) for v in hdr] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode[0] != "F": im = im.convert("F") hdr = makeSpiderHeader(im) if len(hdr) < 256: - raise OSError("Error creating Spider header") + msg = "Error creating Spider header" + raise OSError(msg) # write the SPIDER header fp.writelines(hdr) rawmode = "F;32NF" # 32-bit native floating point - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) -def _save_spider(im, fp, filename): +def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - ext = os.path.splitext(filename)[1] + filename_ext = os.path.splitext(filename)[1] + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) @@ -294,9 +297,8 @@ def _save_spider(im, fp, filename): Image.register_save(SpiderImageFile.format, _save_spider) if __name__ == "__main__": - if len(sys.argv) < 2: - print("Syntax: python SpiderImagePlugin.py [infile] [outfile]") + print("Syntax: python3 SpiderImagePlugin.py [infile] [outfile]") sys.exit() filename = sys.argv[1] @@ -305,10 +307,10 @@ def _save_spider(im, fp, filename): sys.exit() with Image.open(filename) as im: - print("image: " + str(im)) - print("format: " + str(im.format)) - print("size: " + str(im.size)) - print("mode: " + str(im.mode)) + print(f"image: {im}") + print(f"format: {im.format}") + print(f"size: {im.size}") + print(f"mode: {im.mode}") print("max, min: ", end=" ") print(im.getextrema()) @@ -316,7 +318,7 @@ def _save_spider(im, fp, filename): outfile = sys.argv[2] # perform some image operation - im = im.transpose(Image.FLIP_LEFT_RIGHT) + im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) print( f"saving a flipped version of {os.path.basename(filename)} " f"as {outfile} " diff --git a/dependencies/OSX/PIL/SunImagePlugin.py b/dependencies/OSX/PIL/SunImagePlugin.py index d998842..8912379 100644 --- a/dependencies/OSX/PIL/SunImagePlugin.py +++ b/dependencies/OSX/PIL/SunImagePlugin.py @@ -15,13 +15,13 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile, ImagePalette from ._binary import i32be as i32 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 4 and i32(prefix) == 0x59A66A95 @@ -30,12 +30,10 @@ def _accept(prefix): class SunImageFile(ImageFile.ImageFile): - format = "SUN" format_description = "Sun Raster File" - def _open(self): - + def _open(self) -> None: # The Sun Raster file header is 32 bytes in length # and has the following format: @@ -51,51 +49,57 @@ def _open(self): # DWORD ColorMapLength; /* Size of the color map in bytes */ # } SUNRASTER; + assert self.fp is not None + # HEAD s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an SUN raster file") + msg = "not an SUN raster file" + raise SyntaxError(msg) offset = 32 - self._size = i32(s[4:8]), i32(s[8:12]) + self._size = i32(s, 4), i32(s, 8) - depth = i32(s[12:16]) - # data_length = i32(s[16:20]) # unreliable, ignore. - file_type = i32(s[20:24]) - palette_type = i32(s[24:28]) # 0: None, 1: RGB, 2: Raw/arbitrary - palette_length = i32(s[28:32]) + depth = i32(s, 12) + # data_length = i32(s, 16) # unreliable, ignore. + file_type = i32(s, 20) + palette_type = i32(s, 24) # 0: None, 1: RGB, 2: Raw/arbitrary + palette_length = i32(s, 28) if depth == 1: - self.mode, rawmode = "1", "1;I" + self._mode, rawmode = "1", "1;I" elif depth == 4: - self.mode, rawmode = "L", "L;4" + self._mode, rawmode = "L", "L;4" elif depth == 8: - self.mode = rawmode = "L" + self._mode = rawmode = "L" elif depth == 24: if file_type == 3: - self.mode, rawmode = "RGB", "RGB" + self._mode, rawmode = "RGB", "RGB" else: - self.mode, rawmode = "RGB", "BGR" + self._mode, rawmode = "RGB", "BGR" elif depth == 32: if file_type == 3: - self.mode, rawmode = "RGB", "RGBX" + self._mode, rawmode = "RGB", "RGBX" else: - self.mode, rawmode = "RGB", "BGRX" + self._mode, rawmode = "RGB", "BGRX" else: - raise SyntaxError("Unsupported Mode/Bit Depth") + msg = "Unsupported Mode/Bit Depth" + raise SyntaxError(msg) if palette_length: if palette_length > 1024: - raise SyntaxError("Unsupported Color Palette Length") + msg = "Unsupported Color Palette Length" + raise SyntaxError(msg) if palette_type != 1: - raise SyntaxError("Unsupported Palette Type") + msg = "Unsupported Palette Type" + raise SyntaxError(msg) offset = offset + palette_length self.palette = ImagePalette.raw("RGB;L", self.fp.read(palette_length)) if self.mode == "L": - self.mode = "P" + self._mode = "P" rawmode = rawmode.replace("L", "P") # 16 bit boundaries on stride @@ -120,11 +124,16 @@ def _open(self): # (https://www.fileformat.info/format/sunraster/egff.htm) if file_type in (0, 1, 3, 4, 5): - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride)) + ] elif file_type == 2: - self.tile = [("sun_rle", (0, 0) + self.size, offset, rawmode)] + self.tile = [ + ImageFile._Tile("sun_rle", (0, 0) + self.size, offset, rawmode) + ] else: - raise SyntaxError("Unsupported Sun Raster file type") + msg = "Unsupported Sun Raster file type" + raise SyntaxError(msg) # diff --git a/dependencies/OSX/PIL/TarIO.py b/dependencies/OSX/PIL/TarIO.py index d108362..779288b 100644 --- a/dependencies/OSX/PIL/TarIO.py +++ b/dependencies/OSX/PIL/TarIO.py @@ -13,16 +13,17 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io from . import ContainerIO -class TarIO(ContainerIO.ContainerIO): +class TarIO(ContainerIO.ContainerIO[bytes]): """A file object that provides read access to a given member of a TAR file.""" - def __init__(self, tarfile, file): + def __init__(self, tarfile: str, file: str) -> None: """ Create file object. @@ -32,15 +33,16 @@ def __init__(self, tarfile, file): self.fh = open(tarfile, "rb") while True: - s = self.fh.read(512) if len(s) != 512: - raise OSError("unexpected end of tar file") + msg = "unexpected end of tar file" + raise OSError(msg) name = s[:100].decode("utf-8") i = name.find("\0") if i == 0: - raise OSError("cannot find subfile") + msg = "cannot find subfile" + raise OSError(msg) if i > 0: name = name[:i] @@ -53,13 +55,3 @@ def __init__(self, tarfile, file): # Open region super().__init__(self.fh, self.fh.tell(), size) - - # Context manager support - def __enter__(self): - return self - - def __exit__(self, *args): - self.close() - - def close(self): - self.fh.close() diff --git a/dependencies/OSX/PIL/TgaImagePlugin.py b/dependencies/OSX/PIL/TgaImagePlugin.py index 69b3e06..90d5b5c 100644 --- a/dependencies/OSX/PIL/TgaImagePlugin.py +++ b/dependencies/OSX/PIL/TgaImagePlugin.py @@ -15,12 +15,12 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import warnings +from typing import IO from . import Image, ImageFile, ImagePalette -from ._binary import i8 from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 @@ -36,7 +36,7 @@ (3, 1): "1", (3, 8): "L", (3, 16): "LA", - (2, 16): "BGR;5", + (2, 16): "BGRA;15Z", (2, 24): "BGR", (2, 32): "BGRA", } @@ -47,25 +47,25 @@ class TgaImageFile(ImageFile.ImageFile): - format = "TGA" format_description = "Targa" - def _open(self): - + def _open(self) -> None: # process header + assert self.fp is not None + s = self.fp.read(18) - id_len = i8(s[0]) + id_len = s[0] - colormaptype = i8(s[1]) - imagetype = i8(s[2]) + colormaptype = s[1] + imagetype = s[2] - depth = i8(s[16]) + depth = s[16] - flags = i8(s[17]) + flags = s[17] - self._size = i16(s[12:]), i16(s[14:]) + self._size = i16(s, 12), i16(s, 14) # validate header fields if ( @@ -74,32 +74,34 @@ def _open(self): or self.size[1] <= 0 or depth not in (1, 8, 16, 24, 32) ): - raise SyntaxError("not a TGA file") + msg = "not a TGA file" + raise SyntaxError(msg) # image mode if imagetype in (3, 11): - self.mode = "L" + self._mode = "L" if depth == 1: - self.mode = "1" # ??? + self._mode = "1" # ??? elif depth == 16: - self.mode = "LA" + self._mode = "LA" elif imagetype in (1, 9): - self.mode = "P" + self._mode = "P" if colormaptype else "L" elif imagetype in (2, 10): - self.mode = "RGB" - if depth == 32: - self.mode = "RGBA" + self._mode = "RGB" if depth == 24 else "RGBA" else: - raise SyntaxError("unknown TGA mode") + msg = "unknown TGA mode" + raise SyntaxError(msg) # orientation orientation = flags & 0x30 - if orientation == 0x20: + self._flip_horizontally = orientation in [0x10, 0x30] + if orientation in [0x20, 0x30]: orientation = 1 - elif not orientation: + elif orientation in [0, 0x10]: orientation = -1 else: - raise SyntaxError("unknown TGA orientation") + msg = "unknown TGA orientation" + raise SyntaxError(msg) self.info["orientation"] = orientation @@ -111,19 +113,23 @@ def _open(self): if colormaptype: # read palette - start, size, mapdepth = i16(s[3:]), i16(s[5:]), i16(s[7:]) + start, size, mapdepth = i16(s, 3), i16(s, 5), s[7] if mapdepth == 16: self.palette = ImagePalette.raw( - "BGR;16", b"\0" * 2 * start + self.fp.read(2 * size) + "BGRA;15Z", bytes(2 * start) + self.fp.read(2 * size) ) + self.palette.mode = "RGBA" elif mapdepth == 24: self.palette = ImagePalette.raw( - "BGR", b"\0" * 3 * start + self.fp.read(3 * size) + "BGR", bytes(3 * start) + self.fp.read(3 * size) ) elif mapdepth == 32: self.palette = ImagePalette.raw( - "BGRA", b"\0" * 4 * start + self.fp.read(4 * size) + "BGRA", bytes(4 * start) + self.fp.read(4 * size) ) + else: + msg = "unknown TGA map depth" + raise SyntaxError(msg) # setup tile descriptor try: @@ -131,7 +137,7 @@ def _open(self): if imagetype & 8: # compressed self.tile = [ - ( + ImageFile._Tile( "tga_rle", (0, 0) + self.size, self.fp.tell(), @@ -140,7 +146,7 @@ def _open(self): ] else: self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell(), @@ -150,6 +156,10 @@ def _open(self): except KeyError: pass # cannot decode + def load_end(self) -> None: + if self._flip_horizontally: + self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + # # -------------------------------------------------------------------- @@ -166,12 +176,12 @@ def _open(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TGA") from e + msg = f"cannot write mode {im.mode} as TGA" + raise OSError(msg) from e if "rle" in im.encoderinfo: rle = im.encoderinfo["rle"] @@ -189,9 +199,10 @@ def _save(im, fp, filename): warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: - colormapfirst, colormaplength, colormapentry = 0, 256, 24 + palette = im.im.getpalette("RGB", "BGR") + colormaplength, colormapentry = len(palette) // 3, 24 else: - colormapfirst, colormaplength, colormapentry = 0, 0, 0 + colormaplength, colormapentry = 0, 0 if im.mode in ("LA", "RGBA"): flags = 8 @@ -206,7 +217,7 @@ def _save(im, fp, filename): o8(id_len) + o8(colormaptype) + o8(imagetype) - + o16(colormapfirst) + + o16(0) # colormapfirst + o16(colormaplength) + o8(colormapentry) + o16(0) @@ -221,15 +232,19 @@ def _save(im, fp, filename): fp.write(id_section) if colormaptype: - fp.write(im.im.getpalette("RGB", "BGR")) + fp.write(palette) if rle: ImageFile._save( - im, fp, [("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))] + im, + fp, + [ImageFile._Tile("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))], ) else: ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))], ) # write targa version 2 footer diff --git a/dependencies/OSX/PIL/TiffImagePlugin.py b/dependencies/OSX/PIL/TiffImagePlugin.py index bbfd28c..61eb152 100644 --- a/dependencies/OSX/PIL/TiffImagePlugin.py +++ b/dependencies/OSX/PIL/TiffImagePlugin.py @@ -38,26 +38,38 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import itertools import logging +import math import os import struct import warnings -from collections.abc import MutableMapping +from collections.abc import Iterator, MutableMapping from fractions import Fraction from numbers import Number, Rational - -from . import Image, ImageFile, ImagePalette, TiffTags -from ._binary import i8, o8 +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn, cast + +from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags +from ._binary import i16be as i16 +from ._binary import i32be as i32 +from ._binary import o8 +from ._deprecate import deprecate +from ._typing import StrOrBytesPath +from ._util import is_path from .TiffTags import TYPES +if TYPE_CHECKING: + from ._typing import Buffer, IntegralLike + logger = logging.getLogger(__name__) # Set these to true to force use of libtiff for reading or writing. READ_LIBTIFF = False WRITE_LIBTIFF = False -IFD_LEGACY_API = True +STRIP_SIZE = 65536 II = b"II" # little-endian (Intel style) MM = b"MM" # big-endian (Motorola style) @@ -67,6 +79,7 @@ # Read TIFF files # a few tag names, just to make the code below a bit more readable +OSUBFILETYPE = 255 IMAGEWIDTH = 256 IMAGELENGTH = 257 BITSPERSAMPLE = 258 @@ -88,10 +101,15 @@ ARTIST = 315 PREDICTOR = 317 COLORMAP = 320 +TILEWIDTH = 322 +TILELENGTH = 323 TILEOFFSETS = 324 +TILEBYTECOUNTS = 325 +SUBIFD = 330 EXTRASAMPLES = 338 SAMPLEFORMAT = 339 JPEGTABLES = 347 +YCBCRSUBSAMPLING = 530 REFERENCEBLACKWHITE = 532 COPYRIGHT = 33432 IPTC_NAA_CHUNK = 33723 # newsphoto properties @@ -161,11 +179,15 @@ (MM, 0, (1,), 2, (8,), ()): ("L", "L;IR"), (II, 1, (1,), 1, (8,), ()): ("L", "L"), (MM, 1, (1,), 1, (8,), ()): ("L", "L"), + (II, 1, (2,), 1, (8,), ()): ("L", "L"), + (MM, 1, (2,), 1, (8,), ()): ("L", "L"), (II, 1, (1,), 2, (8,), ()): ("L", "L;R"), (MM, 1, (1,), 2, (8,), ()): ("L", "L;R"), (II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"), + (II, 0, (1,), 1, (16,), ()): ("I;16", "I;16"), (II, 1, (1,), 1, (16,), ()): ("I;16", "I;16"), (MM, 1, (1,), 1, (16,), ()): ("I;16B", "I;16B"), + (II, 1, (1,), 2, (16,), ()): ("I;16", "I;16R"), (II, 1, (2,), 1, (16,), ()): ("I", "I;16S"), (MM, 1, (2,), 1, (16,), ()): ("I", "I;16BS"), (II, 0, (3,), 1, (32,), ()): ("F", "F;32F"), @@ -183,12 +205,12 @@ (MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), (II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples (MM, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples - (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), (II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), @@ -207,8 +229,8 @@ (MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"), - (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16L"), - (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"), @@ -227,6 +249,7 @@ (MM, 3, (1,), 2, (4,), ()): ("P", "P;4R"), (II, 3, (1,), 1, (8,), ()): ("P", "P"), (MM, 3, (1,), 1, (8,), ()): ("P", "P"), + (II, 3, (1,), 1, (8, 8), (0,)): ("P", "PX"), (II, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), (MM, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), (II, 3, (1,), 2, (8,), ()): ("P", "P;R"), @@ -238,6 +261,9 @@ (II, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (MM, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (II, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16L"), + (MM, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16B"), + (II, 6, (1,), 1, (8,), ()): ("L", "L"), + (MM, 6, (1,), 1, (8,), ()): ("L", "L"), # JPEG compressed images handled by LibTiff and auto-converted to RGBX # Minimal Baseline TIFF requires YCbCr images to have 3 SamplesPerPixel (II, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"), @@ -246,34 +272,45 @@ (MM, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"), } +MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO) + PREFIXES = [ b"MM\x00\x2A", # Valid TIFF header with big-endian byte order b"II\x2A\x00", # Valid TIFF header with little-endian byte order b"MM\x2A\x00", # Invalid TIFF header, assume big-endian b"II\x00\x2A", # Invalid TIFF header, assume little-endian + b"MM\x00\x2B", # BigTIFF with big-endian byte order + b"II\x2B\x00", # BigTIFF with little-endian byte order ] +if not getattr(Image.core, "libtiff_support_custom_tags", True): + deprecate("Support for LibTIFF earlier than version 4", 12) + -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] in PREFIXES -def _limit_rational(val, max_val): +def _limit_rational( + val: float | Fraction | IFDRational, max_val: int +) -> tuple[IntegralLike, IntegralLike]: inv = abs(val) > 1 n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) return n_d[::-1] if inv else n_d -def _limit_signed_rational(val, max_val, min_val): +def _limit_signed_rational( + val: IFDRational, max_val: int, min_val: int +) -> tuple[IntegralLike, IntegralLike]: frac = Fraction(val) - n_d = frac.numerator, frac.denominator + n_d: tuple[IntegralLike, IntegralLike] = frac.numerator, frac.denominator - if min(n_d) < min_val: + if min(float(i) for i in n_d) < min_val: n_d = _limit_rational(val, abs(min_val)) - if max(n_d) > max_val: - val = Fraction(*n_d) - n_d = _limit_rational(val, max_val) + n_d_float = tuple(float(i) for i in n_d) + if max(n_d_float) > max_val: + n_d = _limit_rational(n_d_float[0] / n_d_float[1], max_val) return n_d @@ -285,6 +322,15 @@ def _limit_signed_rational(val, max_val, min_val): _write_dispatch = {} +def _delegate(op: str) -> Any: + def delegate( + self: IFDRational, *args: tuple[float, ...] + ) -> bool | float | Fraction: + return getattr(self._val, op)(*args) + + return delegate + + class IFDRational(Rational): """Implements a rational class where 0/0 is a legal value to match the in the wild use of exif rationals. @@ -299,12 +345,15 @@ class IFDRational(Rational): __slots__ = ("_numerator", "_denominator", "_val") - def __init__(self, value, denominator=1): + def __init__( + self, value: float | Fraction | IFDRational, denominator: int = 1 + ) -> None: """ :param value: either an integer numerator, a float/rational/other number, or an IFDRational :param denominator: Optional integer denominator """ + self._val: Fraction | float if isinstance(value, IFDRational): self._numerator = value.numerator self._denominator = value.denominator @@ -315,25 +364,30 @@ def __init__(self, value, denominator=1): self._numerator = value.numerator self._denominator = value.denominator else: - self._numerator = value + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, value) + else: + self._numerator = value self._denominator = denominator if denominator == 0: self._val = float("nan") elif denominator == 1: self._val = Fraction(value) + elif int(value) == value: + self._val = Fraction(int(value), denominator) else: - self._val = Fraction(value, denominator) + self._val = Fraction(value / denominator) @property - def numerator(a): - return a._numerator + def numerator(self) -> IntegralLike: + return self._numerator @property - def denominator(a): - return a._denominator + def denominator(self) -> int: + return self._denominator - def limit_rational(self, max_denominator): + def limit_rational(self, max_denominator: int) -> tuple[IntegralLike, int]: """ :param max_denominator: Integer, the maximum denominator value @@ -341,27 +395,40 @@ def limit_rational(self, max_denominator): """ if self.denominator == 0: - return (self.numerator, self.denominator) + return self.numerator, self.denominator + assert isinstance(self._val, Fraction) f = self._val.limit_denominator(max_denominator) - return (f.numerator, f.denominator) + return f.numerator, f.denominator - def __repr__(self): + def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self): + def __hash__(self) -> int: return self._val.__hash__() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + val = self._val if isinstance(other, IFDRational): other = other._val - return self._val == other - - def _delegate(op): - def delegate(self, *args): - return getattr(self._val, op)(*args) - - return delegate + if isinstance(other, float): + val = float(val) + return val == other + + def __getstate__(self) -> list[float | Fraction | IntegralLike]: + return [self._val, self._numerator, self._denominator] + + def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: + IFDRational.__init__(self, 0) + _val, _numerator, _denominator = state + assert isinstance(_val, (float, Fraction)) + self._val = _val + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, _numerator) + else: + self._numerator = _numerator + assert isinstance(_denominator, int) + self._denominator = _denominator """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', 'truediv', 'rtruediv', 'floordiv', 'rfloordiv', @@ -397,9 +464,59 @@ def delegate(self, *args): __ceil__ = _delegate("__ceil__") __floor__ = _delegate("__floor__") __round__ = _delegate("__round__") + # Python >= 3.11 + if hasattr(Fraction, "__int__"): + __int__ = _delegate("__int__") + + +_LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] + + +def _register_loader(idx: int, size: int) -> Callable[[_LoaderFunc], _LoaderFunc]: + def decorator(func: _LoaderFunc) -> _LoaderFunc: + from .TiffTags import TYPES + if func.__name__.startswith("load_"): + TYPES[idx] = func.__name__[5:].replace("_", " ") + _load_dispatch[idx] = size, func # noqa: F821 + return func -class ImageFileDirectory_v2(MutableMapping): + return decorator + + +def _register_writer(idx: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _write_dispatch[idx] = func # noqa: F821 + return func + + return decorator + + +def _register_basic(idx_fmt_name: tuple[int, str, str]) -> None: + from .TiffTags import TYPES + + idx, fmt, name = idx_fmt_name + TYPES[idx] = name + size = struct.calcsize(f"={fmt}") + + def basic_handler( + self: ImageFileDirectory_v2, data: bytes, legacy_api: bool = True + ) -> tuple[Any, ...]: + return self._unpack(f"{len(data) // size}{fmt}", data) + + _load_dispatch[idx] = size, basic_handler # noqa: F821 + _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 + b"".join(self._pack(fmt, value) for value in values) + ) + + +if TYPE_CHECKING: + _IFDv2Base = MutableMapping[int, Any] +else: + _IFDv2Base = MutableMapping + + +class ImageFileDirectory_v2(_IFDv2Base): """This class represents a TIFF tag directory. To speed things up, we don't decode tags unless they're asked for. @@ -422,39 +539,53 @@ class ImageFileDirectory_v2(MutableMapping): Data Structures: - * self.tagtype = {} + * ``self.tagtype = {}`` - * Key: numerical tiff tag number + * Key: numerical TIFF tag number * Value: integer corresponding to the data type from - ~PIL.TiffTags.TYPES` + :py:data:`.TiffTags.TYPES` - .. versionadded:: 3.0.0 - """ + .. versionadded:: 3.0.0 - """ - Documentation: - - 'internal' data structures: - * self._tags_v2 = {} Key: numerical tiff tag number - Value: decoded data, as tuple for multiple values - * self._tagdata = {} Key: numerical tiff tag number - Value: undecoded byte string from file - * self._tags_v1 = {} Key: numerical tiff tag number - Value: decoded data in the v1 format - - Tags will be found in the private attributes self._tagdata, and in - self._tags_v2 once decoded. - - Self.legacy_api is a value for internal use, and shouldn't be - changed from outside code. In cooperation with the - ImageFileDirectory_v1 class, if legacy_api is true, then decoded - tags will be populated into both _tags_v1 and _tags_v2. _Tags_v2 - will be used if this IFD is used in the TIFF save routine. Tags - should be read from tags_v1 if legacy_api == true. + 'Internal' data structures: + + * ``self._tags_v2 = {}`` + + * Key: numerical TIFF tag number + * Value: decoded data, as tuple for multiple values + + * ``self._tagdata = {}`` + + * Key: numerical TIFF tag number + * Value: undecoded byte string from file + + * ``self._tags_v1 = {}`` + + * Key: numerical TIFF tag number + * Value: decoded data in the v1 format + + Tags will be found in the private attributes ``self._tagdata``, and in + ``self._tags_v2`` once decoded. + + ``self.legacy_api`` is a value for internal use, and shouldn't be changed + from outside code. In cooperation with + :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1`, if ``legacy_api`` + is true, then decoded tags will be populated into both ``_tags_v1`` and + ``_tags_v2``. ``_tags_v2`` will be used if this IFD is used in the TIFF + save routine. Tags should be read from ``_tags_v1`` if + ``legacy_api == true``. """ - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None): + _load_dispatch: dict[int, tuple[int, _LoaderFunc]] = {} + _write_dispatch: dict[int, Callable[..., Any]] = {} + + def __init__( + self, + ifh: bytes = b"II\x2A\x00\x00\x00\x00\x00", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -465,52 +596,67 @@ def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None): endianness. :param prefix: Override the endianness of the file. """ - if ifh[:4] not in PREFIXES: - raise SyntaxError(f"not a TIFF file (header {repr(ifh)} not valid)") + if not _accept(ifh): + msg = f"not a TIFF file (header {repr(ifh)} not valid)" + raise SyntaxError(msg) self._prefix = prefix if prefix is not None else ifh[:2] if self._prefix == MM: self._endian = ">" elif self._prefix == II: self._endian = "<" else: - raise SyntaxError("not a TIFF IFD") - self.tagtype = {} + msg = "not a TIFF IFD" + raise SyntaxError(msg) + self._bigtiff = ifh[2] == 43 + self.group = group + self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() - (self.next,) = self._unpack("L", ifh[4:]) + self.next = ( + self._unpack("Q", ifh[8:])[0] + if self._bigtiff + else self._unpack("L", ifh[4:])[0] + ) self._legacy_api = False prefix = property(lambda self: self._prefix) offset = property(lambda self: self._offset) - legacy_api = property(lambda self: self._legacy_api) - @legacy_api.setter - def legacy_api(self, value): - raise Exception("Not allowing setting of legacy api") + @property + def legacy_api(self) -> bool: + return self._legacy_api - def reset(self): - self._tags_v1 = {} # will remain empty if legacy_api is false - self._tags_v2 = {} # main tag storage - self._tagdata = {} + @legacy_api.setter + def legacy_api(self, value: bool) -> NoReturn: + msg = "Not allowing setting of legacy api" + raise Exception(msg) + + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None - self._offset = None + self._offset: int | None = None - def __str__(self): + def __str__(self) -> str: return str(dict(self)) - def named(self): + def named(self) -> dict[str, Any]: """ :returns: dict of name|key: value Returns the complete tag dictionary, with named tags where possible. """ - return {TiffTags.lookup(code).name: value for code, value in self.items()} + return { + TiffTags.lookup(code, self.group).name: value + for code, value in self.items() + } - def __len__(self): + def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v2)) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v2: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -521,16 +667,16 @@ def __getitem__(self, tag): val = (val,) return val - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v2 or tag in self._tagdata - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: self._setitem(tag, value, self.legacy_api) - def _setitem(self, tag, value, legacy_api): + def _setitem(self, tag: int, value: Any, legacy_api: bool) -> None: basetypes = (Number, bytes, str) - info = TiffTags.lookup(tag) + info = TiffTags.lookup(tag, self.group) values = [value] if isinstance(value, basetypes) else value if tag not in self.tagtype: @@ -539,22 +685,33 @@ def _setitem(self, tag, value, legacy_api): else: self.tagtype[tag] = TiffTags.UNDEFINED if all(isinstance(v, IFDRational) for v in values): - self.tagtype[tag] = ( - TiffTags.RATIONAL - if all(v >= 0 for v in values) - else TiffTags.SIGNED_RATIONAL - ) + for v in values: + assert isinstance(v, IFDRational) + if v < 0: + self.tagtype[tag] = TiffTags.SIGNED_RATIONAL + break + else: + self.tagtype[tag] = TiffTags.RATIONAL elif all(isinstance(v, int) for v in values): - if all(0 <= v < 2 ** 16 for v in values): + short = True + signed_short = True + long = True + for v in values: + assert isinstance(v, int) + if short and not (0 <= v < 2**16): + short = False + if signed_short and not (-(2**15) < v < 2**15): + signed_short = False + if long and v < 0: + long = False + if short: self.tagtype[tag] = TiffTags.SHORT - elif all(-(2 ** 15) < v < 2 ** 15 for v in values): + elif signed_short: self.tagtype[tag] = TiffTags.SIGNED_SHORT + elif long: + self.tagtype[tag] = TiffTags.LONG else: - self.tagtype[tag] = ( - TiffTags.LONG - if all(v >= 0 for v in values) - else TiffTags.SIGNED_LONG - ) + self.tagtype[tag] = TiffTags.SIGNED_LONG elif all(isinstance(v, float) for v in values): self.tagtype[tag] = TiffTags.DOUBLE elif all(isinstance(v, str) for v in values): @@ -564,14 +721,18 @@ def _setitem(self, tag, value, legacy_api): if self.tagtype[tag] == TiffTags.UNDEFINED: values = [ - value.encode("ascii", "replace") if isinstance(value, str) else value + v.encode("ascii", "replace") if isinstance(v, str) else v + for v in values ] elif self.tagtype[tag] == TiffTags.RATIONAL: values = [float(v) if isinstance(v, int) else v for v in values] is_ifd = self.tagtype[tag] == TiffTags.LONG and isinstance(values, dict) if not is_ifd: - values = tuple(info.cvt_enum(value) for value in values) + values = tuple( + info.cvt_enum(value) if isinstance(value, str) else value + for value in values + ) dest = self._tags_v1 if legacy_api else self._tags_v2 @@ -606,54 +767,20 @@ def _setitem(self, tag, value, legacy_api): # Unspec'd, and length > 1 dest[tag] = values - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: self._tags_v2.pop(tag, None) self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v2)) - def _unpack(self, fmt, data): + def _unpack(self, fmt: str, data: bytes) -> tuple[Any, ...]: return struct.unpack(self._endian + fmt, data) - def _pack(self, fmt, *values): + def _pack(self, fmt: str, *values: Any) -> bytes: return struct.pack(self._endian + fmt, *values) - def _register_loader(idx, size): - def decorator(func): - from .TiffTags import TYPES - - if func.__name__.startswith("load_"): - TYPES[idx] = func.__name__[5:].replace("_", " ") - _load_dispatch[idx] = size, func # noqa: F821 - return func - - return decorator - - def _register_writer(idx): - def decorator(func): - _write_dispatch[idx] = func # noqa: F821 - return func - - return decorator - - def _register_basic(idx_fmt_name): - from .TiffTags import TYPES - - idx, fmt, name = idx_fmt_name - TYPES[idx] = name - size = struct.calcsize("=" + fmt) - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack("{}{}".format(len(data) // size, fmt), data) - ), - ) - _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 - b"".join(self._pack(fmt, value) for value in values) - ) - list( map( _register_basic, @@ -666,99 +793,125 @@ def _register_basic(idx_fmt_name): (TiffTags.FLOAT, "f", "float"), (TiffTags.DOUBLE, "d", "double"), (TiffTags.IFD, "L", "long"), + (TiffTags.LONG8, "Q", "long8"), ], ) ) @_register_loader(1, 1) # Basic type, except for the legacy API. - def load_byte(self, data, legacy_api=True): + def load_byte(self, data: bytes, legacy_api: bool = True) -> bytes: return data @_register_writer(1) # Basic type, except for the legacy API. - def write_byte(self, data): + def write_byte(self, data: bytes | int | IFDRational) -> bytes: + if isinstance(data, IFDRational): + data = int(data) + if isinstance(data, int): + data = bytes((data,)) return data @_register_loader(2, 1) - def load_string(self, data, legacy_api=True): + def load_string(self, data: bytes, legacy_api: bool = True) -> str: if data.endswith(b"\0"): data = data[:-1] return data.decode("latin-1", "replace") @_register_writer(2) - def write_string(self, value): + def write_string(self, value: str | bytes | int) -> bytes: # remerge of https://github.com/python-pillow/Pillow/pull/1416 - return b"" + value.encode("ascii", "replace") + b"\0" + if isinstance(value, int): + value = str(value) + if not isinstance(value, bytes): + value = value.encode("ascii", "replace") + return value + b"\0" @_register_loader(5, 8) - def load_rational(self, data, legacy_api=True): - vals = self._unpack("{}L".format(len(data) // 4), data) + def load_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}L", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(5) - def write_rational(self, *values): + def write_rational(self, *values: IFDRational) -> bytes: return b"".join( - self._pack("2L", *_limit_rational(frac, 2 ** 32 - 1)) for frac in values + self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values ) @_register_loader(7, 1) - def load_undefined(self, data, legacy_api=True): + def load_undefined(self, data: bytes, legacy_api: bool = True) -> bytes: return data @_register_writer(7) - def write_undefined(self, value): + def write_undefined(self, value: bytes | int | IFDRational) -> bytes: + if isinstance(value, IFDRational): + value = int(value) + if isinstance(value, int): + value = str(value).encode("ascii", "replace") return value @_register_loader(10, 8) - def load_signed_rational(self, data, legacy_api=True): - vals = self._unpack("{}l".format(len(data) // 4), data) + def load_signed_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}l", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(10) - def write_signed_rational(self, *values): + def write_signed_rational(self, *values: IFDRational) -> bytes: return b"".join( - self._pack("2l", *_limit_signed_rational(frac, 2 ** 31 - 1, -(2 ** 31))) + self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) for frac in values ) - def _ensure_read(self, fp, size): + def _ensure_read(self, fp: IO[bytes], size: int) -> bytes: ret = fp.read(size) if len(ret) != size: - raise OSError( + msg = ( "Corrupt EXIF data. " f"Expecting to read {size} bytes but only got {len(ret)}. " ) + raise OSError(msg) return ret - def load(self, fp): - + def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() try: - for i in range(self._unpack("H", self._ensure_read(fp, 2))[0]): - tag, typ, count, data = self._unpack("HHL4s", self._ensure_read(fp, 12)) + tag_count = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("H", self._ensure_read(fp, 2)) + )[0] + for i in range(tag_count): + tag, typ, count, data = ( + self._unpack("HHQ8s", self._ensure_read(fp, 20)) + if self._bigtiff + else self._unpack("HHL4s", self._ensure_read(fp, 12)) + ) - tagname = TiffTags.lookup(tag).name + tagname = TiffTags.lookup(tag, self.group).name typname = TYPES.get(typ, "unknown") msg = f"tag: {tagname} ({tag}) - type: {typname} ({typ})" try: unit_size, handler = self._load_dispatch[typ] except KeyError: - logger.debug(msg + f" - unsupported type {typ}") + logger.debug("%s - unsupported type %s", msg, typ) continue # ignore unsupported type size = count * unit_size - if size > 4: + if size > (8 if self._bigtiff else 4): here = fp.tell() - (offset,) = self._unpack("L", data) + (offset,) = self._unpack("Q" if self._bigtiff else "L", data) msg += f" Tag Location: {here} - Data Location: {offset}" fp.seek(offset) data = ImageFile._safe_read(fp, size) @@ -782,51 +935,60 @@ def load(self, fp): self._tagdata[tag] = data self.tagtype[tag] = typ - msg += " - value: " + ( - "" % size if size > 32 else repr(data) - ) + msg += " - value: " + msg += f"" if size > 32 else repr(data) + logger.debug(msg) - (self.next,) = self._unpack("L", self._ensure_read(fp, 4)) + (self.next,) = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("L", self._ensure_read(fp, 4)) + ) except OSError as msg: warnings.warn(str(msg)) return - def tobytes(self, offset=0): + def _get_ifh(self): + ifh = self._prefix + self._pack("H", 43 if self._bigtiff else 42) + if self._bigtiff: + ifh += self._pack("HH", 8, 0) + ifh += self._pack("Q", 16) if self._bigtiff else self._pack("L", 8) + + return ifh + + def tobytes(self, offset: int = 0) -> bytes: # FIXME What about tagdata? - result = self._pack("H", len(self._tags_v2)) + result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) - entries = [] - offset = offset + len(result) + len(self._tags_v2) * 12 + 4 + entries: list[tuple[int, int, int, bytes, bytes]] = [] + offset += len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + 4 stripoffsets = None # pass 1: convert tags to binary format # always write tags in ascending order + fmt = "Q" if self._bigtiff else "L" + fmt_size = 8 if self._bigtiff else 4 for tag, value in sorted(self._tags_v2.items()): if tag == STRIPOFFSETS: stripoffsets = len(entries) - typ = self.tagtype.get(tag) - logger.debug(f"Tag {tag}, Type: {typ}, Value: {repr(value)}") + typ = self.tagtype[tag] + logger.debug("Tag %s, Type: %s, Value: %s", tag, typ, repr(value)) is_ifd = typ == TiffTags.LONG and isinstance(value, dict) if is_ifd: - if self._endian == "<": - ifh = b"II\x2A\x00\x08\x00\x00\x00" - else: - ifh = b"MM\x00\x2A\x00\x00\x00\x08" - ifd = ImageFileDirectory_v2(ifh) - for ifd_tag, ifd_value in self._tags_v2[tag].items(): + ifd = ImageFileDirectory_v2(self._get_ifh(), group=tag) + values = self._tags_v2[tag] + for ifd_tag, ifd_value in values.items(): ifd[ifd_tag] = ifd_value data = ifd.tobytes(offset) else: values = value if isinstance(value, tuple) else (value,) data = self._write_dispatch[typ](self, *values) - tagname = TiffTags.lookup(tag).name + tagname = TiffTags.lookup(tag, self.group).name typname = "ifd" if is_ifd else TYPES.get(typ, "unknown") - msg = f"save: {tagname} ({tag}) - type: {typname} ({typ})" - msg += " - value: " + ( - "" % len(data) if len(data) >= 16 else str(values) - ) + msg = f"save: {tagname} ({tag}) - type: {typname} ({typ}) - value: " + msg += f"" if len(data) >= 16 else str(values) logger.debug(msg) # count is sum of lengths for string and arbitrary data @@ -837,24 +999,29 @@ def tobytes(self, offset=0): else: count = len(values) # figure out if data fits into the entry - if len(data) <= 4: - entries.append((tag, typ, count, data.ljust(4, b"\0"), b"")) + if len(data) <= fmt_size: + entries.append((tag, typ, count, data.ljust(fmt_size, b"\0"), b"")) else: - entries.append((tag, typ, count, self._pack("L", offset), data)) + entries.append((tag, typ, count, self._pack(fmt, offset), data)) offset += (len(data) + 1) // 2 * 2 # pad to word # update strip offset data to point beyond auxiliary data if stripoffsets is not None: tag, typ, count, value, data = entries[stripoffsets] if data: - raise NotImplementedError("multistrip support not yet implemented") - value = self._pack("L", self._unpack("L", value)[0] + offset) + size, handler = self._load_dispatch[typ] + values = [val + offset for val in handler(self, data, self.legacy_api)] + data = self._write_dispatch[typ](self, *values) + else: + value = self._pack(fmt, self._unpack(fmt, value)[0] + offset) entries[stripoffsets] = tag, typ, count, value, data # pass 2: write entries to file for tag, typ, count, value, data in entries: - logger.debug(f"{tag} {typ} {count} {repr(value)} {repr(data)}") - result += self._pack("HHL4s", tag, typ, count, value) + logger.debug("%s %s %s %s %s", tag, typ, count, repr(value), repr(data)) + result += self._pack( + "HHQ8s" if self._bigtiff else "HHL4s", tag, typ, count, value + ) # -- overwrite here for multi-page -- result += b"\0\0\0\0" # end of entries @@ -867,11 +1034,9 @@ def tobytes(self, offset=0): return result - def save(self, fp): - + def save(self, fp: IO[bytes]) -> int: if fp.tell() == 0: # skip TIFF header on subsequent pages - # tiff header -- PIL always starts the first IFD at offset 8 - fp.write(self._prefix + self._pack("HL", 42, 8)) + fp.write(self._get_ifh()) offset = fp.tell() result = self.tobytes(offset) @@ -883,8 +1048,8 @@ def save(self, fp): ImageFileDirectory_v2._write_dispatch = _write_dispatch for idx, name in TYPES.items(): name = name.replace(" ", "_") - setattr(ImageFileDirectory_v2, "load_" + name, _load_dispatch[idx][1]) - setattr(ImageFileDirectory_v2, "write_" + name, _write_dispatch[idx]) + setattr(ImageFileDirectory_v2, f"load_{name}", _load_dispatch[idx][1]) + setattr(ImageFileDirectory_v2, f"write_{name}", _write_dispatch[idx]) del _load_dispatch, _write_dispatch, idx, name @@ -908,7 +1073,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): .. deprecated:: 3.0.0 """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._legacy_api = True @@ -916,11 +1081,11 @@ def __init__(self, *args, **kwargs): tagdata = property(lambda self: self._tagdata) # defined in ImageFileDirectory_v2 - tagtype: dict + tagtype: dict[int, int] """Dictionary of tag types""" @classmethod - def from_v2(cls, original): + def from_v2(cls, original: ImageFileDirectory_v2) -> ImageFileDirectory_v1: """Returns an :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` instance with the same data as is contained in the original @@ -937,7 +1102,7 @@ def from_v2(cls, original): ifd.next = original.next # an indicator for multipage tiffs return ifd - def to_v2(self): + def to_v2(self) -> ImageFileDirectory_v2: """Returns an :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` instance with the same data as is contained in the original @@ -954,20 +1119,20 @@ def to_v2(self): ifd._tags_v2 = dict(self._tags_v2) return ifd - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v1 or tag in self._tagdata - def __len__(self): + def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v1)) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v1)) - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: for legacy_api in (False, True): self._setitem(tag, value, legacy_api) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v1: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -980,7 +1145,7 @@ def __getitem__(self, tag): return val -# undone -- switch this pointer when IFD_LEGACY_API == False +# undone -- switch this pointer ImageFileDirectory = ImageFileDirectory_v1 @@ -989,83 +1154,97 @@ def __getitem__(self, tag): class TiffImageFile(ImageFile.ImageFile): - format = "TIFF" format_description = "Adobe TIFF" _close_exclusive_fp_after_loading = False - def __init__(self, fp=None, filename=None): - self.tag_v2 = None + def __init__( + self, + fp: StrOrBytesPath | IO[bytes], + filename: str | bytes | None = None, + ) -> None: + self.tag_v2: ImageFileDirectory_v2 """ Image file directory (tag dictionary) """ - self.tag = None + self.tag: ImageFileDirectory_v1 """ Legacy tag entries """ super().__init__(fp, filename) - def _open(self): + def _open(self) -> None: """Open the first image in a TIFF file""" # Header ifh = self.fp.read(8) + if ifh[2] == 43: + ifh += self.fp.read(8) self.tag_v2 = ImageFileDirectory_v2(ifh) - # legacy IFD entries will be filled in later - self.ifd = None - # setup frame pointers self.__first = self.__next = self.tag_v2.next self.__frame = -1 - self.__fp = self.fp - self._frame_pos = [] - self._n_frames = None + self._fp = self.fp + self._frame_pos: list[int] = [] + self._n_frames: int | None = None logger.debug("*** TiffImageFile._open ***") - logger.debug(f"- __first: {self.__first}") - logger.debug(f"- ifh: {repr(ifh)}") # Use repr to avoid str(bytes) + logger.debug("- __first: %s", self.__first) + logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes) # and load the first frame self._seek(0) @property - def n_frames(self): - if self._n_frames is None: + def n_frames(self) -> int: + current_n_frames = self._n_frames + if current_n_frames is None: current = self.tell() self._seek(len(self._frame_pos)) while self._n_frames is None: self._seek(self.tell() + 1) self.seek(current) + assert self._n_frames is not None return self._n_frames - def seek(self, frame): + def seek(self, frame: int) -> None: """Select a given frame as current image""" if not self._seek_check(frame): return self._seek(frame) - # Create a new core image object on second and - # subsequent frames in the image. Image may be - # different size/mode. - Image._decompression_bomb_check(self.size) - self.im = Image.core.new(self.mode, self.size) - - def _seek(self, frame): - self.fp = self.__fp + if self._im is not None and ( + self.im.size != self._tile_size or self.im.mode != self.mode + ): + # The core image will no longer be used + self._im = None + + def _seek(self, frame: int) -> None: + self.fp = self._fp + while len(self._frame_pos) <= frame: if not self.__next: - raise EOFError("no more images in TIFF file") + msg = "no more images in TIFF file" + raise EOFError(msg) logger.debug( - f"Seeking to frame {frame}, on frame {self.__frame}, " - f"__next {self.__next}, location: {self.fp.tell()}" + "Seeking to frame %s, on frame %s, __next %s, location: %s", + frame, + self.__frame, + self.__next, + self.fp.tell(), ) - # reset buffered io handle in case fp - # was passed to libtiff, invalidating the buffer - self.fp.tell() + if self.__next >= 2**63: + msg = "Unable to seek to frame" + raise ValueError(msg) self.fp.seek(self.__next) self._frame_pos.append(self.__next) - logger.debug("Loading tags, location: %s" % self.fp.tell()) + logger.debug("Loading tags, location: %s", self.fp.tell()) self.tag_v2.load(self.fp) - self.__next = self.tag_v2.next + if self.tag_v2.next in self._frame_pos: + # This IFD has already been processed + # Declare this to be the end of the image + self.__next = 0 + else: + self.__next = self.tag_v2.next if self.__next == 0: self._n_frames = frame + 1 if len(self._frame_pos) == 1: @@ -1073,41 +1252,70 @@ def _seek(self, frame): self.__frame += 1 self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + self.info["xmp"] = self.tag_v2[XMP] + elif "xmp" in self.info: + del self.info["xmp"] + self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) self.__frame = frame self._setup() - def tell(self): + def tell(self) -> int: """Return the current frame number""" return self.__frame - def load(self): + def get_photoshop_blocks(self) -> dict[int, dict[str, bytes]]: + """ + Returns a dictionary of Photoshop "Image Resource Blocks". + The keys are the image resource ID. For more information, see + https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_pgfId-1037727 + + :returns: Photoshop "Image Resource Blocks" in a dictionary. + """ + blocks = {} + val = self.tag_v2.get(ExifTags.Base.ImageResources) + if val: + while val[:4] == b"8BIM": + id = i16(val[4:6]) + n = math.ceil((val[6] + 1) / 2) * 2 + size = i32(val[6 + n : 10 + n]) + data = val[10 + n : 10 + n + size] + blocks[id] = {"data": data} + + val = val[math.ceil((10 + n + size) / 2) * 2 :] + return blocks + + def load(self) -> Image.core.PixelAccess | None: if self.tile and self.use_load_libtiff: return self._load_libtiff() return super().load() - def load_end(self): - if self._tile_orientation: - method = { - 2: Image.FLIP_LEFT_RIGHT, - 3: Image.ROTATE_180, - 4: Image.FLIP_TOP_BOTTOM, - 5: Image.TRANSPOSE, - 6: Image.ROTATE_270, - 7: Image.TRANSVERSE, - 8: Image.ROTATE_90, - }.get(self._tile_orientation) - if method is not None: - self.im = self.im.transpose(method) - self._size = self.im.size + def load_prepare(self) -> None: + if self._im is None: + Image._decompression_bomb_check(self._tile_size) + self.im = Image.core.new(self.mode, self._tile_size) + ImageFile.ImageFile.load_prepare(self) + def load_end(self) -> None: # allow closing if we're on the first frame, there's no next # This is the ImageFile.load path only, libtiff specific below. if not self.is_animated: self._close_exclusive_fp_after_loading = True - def _load_libtiff(self): + # load IFD data from fp before it is closed + exif = self.getexif() + for key in TiffTags.TAGS_V2_GROUPS: + if key not in exif: + continue + exif.get_ifd(key) + + ImageOps.exif_transpose(self, in_place=True) + if ExifTags.Base.Orientation in self.tag_v2: + del self.tag_v2[ExifTags.Base.Orientation] + + def _load_libtiff(self) -> Image.core.PixelAccess | None: """Overload method triggered when we detect a compressed tiff Calls out to libtiff""" @@ -1116,19 +1324,19 @@ def _load_libtiff(self): self.load_prepare() if not len(self.tile) == 1: - raise OSError("Not exactly one tile") + msg = "Not exactly one tile" + raise OSError(msg) # (self._compression, (extents tuple), # 0, (rawmode, self._compression, fp)) extents = self.tile[0][1] - args = list(self.tile[0][3]) + args = self.tile[0][3] # To be nice on memory footprint, if there's a # file descriptor, use that instead of reading # into a string in python. - # libtiff closes the file descriptor, so pass in a dup. try: - fp = hasattr(self.fp, "fileno") and os.dup(self.fp.fileno()) + fp = hasattr(self.fp, "fileno") and self.fp.fileno() # flush the file descriptor, prevents error on pypy 2.4+ # should also eliminate the need for fp.tell # in _seek @@ -1140,15 +1348,17 @@ def _load_libtiff(self): fp = False if fp: - args[2] = fp + assert isinstance(args, tuple) + args_list = list(args) + args_list[2] = fp + args = tuple(args_list) - decoder = Image._getdecoder( - self.mode, "libtiff", tuple(args), self.decoderconfig - ) + decoder = Image._getdecoder(self.mode, "libtiff", args, self.decoderconfig) try: decoder.setimage(self.im, extents) except ValueError as e: - raise OSError("Couldn't set the image") from e + msg = "Couldn't set the image" + raise OSError(msg) from e close_self_fp = self._exclusive_fp and not self.is_animated if hasattr(self.fp, "getvalue"): @@ -1168,8 +1378,17 @@ def _load_libtiff(self): logger.debug("have fileno, calling fileno version of the decoder.") if not close_self_fp: self.fp.seek(0) + # Save and restore the file position, because libtiff will move it + # outside of the Python runtime, and that will confuse + # io.BufferedReader and possible others. + # NOTE: This must use os.lseek(), and not fp.tell()/fp.seek(), + # because the buffer read head already may not equal the actual + # file position, and fp.seek() may just adjust it's internal + # pointer and not actually seek the OS file handle. + pos = os.lseek(fp, 0, os.SEEK_CUR) # 4 bytes, otherwise the trace might error out n, err = decoder.decode(b"fpfp") + os.lseek(fp, pos, os.SEEK_SET) else: # we have something else. logger.debug("don't have fileno or getvalue. just reading") @@ -1182,7 +1401,6 @@ def _load_libtiff(self): self.load_end() - # libtiff closed the fp in a, we need to close self.fp, if possible if close_self_fp: self.fp.close() self.fp = None # might be shared @@ -1192,11 +1410,12 @@ def _load_libtiff(self): return Image.Image.load(self) - def _setup(self): + def _setup(self) -> None: """Setup this image object based on current tags""" if 0xBC01 in self.tag_v2: - raise OSError("Windows Media Photo files not yet supported") + msg = "Windows Media Photo files not yet supported" + raise OSError(msg) # extract relevant tags self._compression = COMPRESSION_INFO[self.tag_v2.get(COMPRESSION, 1)] @@ -1213,27 +1432,39 @@ def _setup(self): fillorder = self.tag_v2.get(FILLORDER, 1) logger.debug("*** Summary ***") - logger.debug(f"- compression: {self._compression}") - logger.debug(f"- photometric_interpretation: {photo}") - logger.debug(f"- planar_configuration: {self._planar_configuration}") - logger.debug(f"- fill_order: {fillorder}") - logger.debug(f"- YCbCr subsampling: {self.tag.get(530)}") + logger.debug("- compression: %s", self._compression) + logger.debug("- photometric_interpretation: %s", photo) + logger.debug("- planar_configuration: %s", self._planar_configuration) + logger.debug("- fill_order: %s", fillorder) + logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) # size - xsize = int(self.tag_v2.get(IMAGEWIDTH)) - ysize = int(self.tag_v2.get(IMAGELENGTH)) - self._size = xsize, ysize + try: + xsize = self.tag_v2[IMAGEWIDTH] + ysize = self.tag_v2[IMAGELENGTH] + except KeyError as e: + msg = "Missing dimensions" + raise TypeError(msg) from e + if not isinstance(xsize, int) or not isinstance(ysize, int): + msg = "Invalid dimensions" + raise ValueError(msg) + self._tile_size = xsize, ysize + orientation = self.tag_v2.get(ExifTags.Base.Orientation) + if orientation in (5, 6, 7, 8): + self._size = ysize, xsize + else: + self._size = xsize, ysize - logger.debug(f"- size: {self.size}") + logger.debug("- size: %s", self.size) - sampleFormat = self.tag_v2.get(SAMPLEFORMAT, (1,)) - if len(sampleFormat) > 1 and max(sampleFormat) == min(sampleFormat) == 1: + sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) + if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1: # SAMPLEFORMAT is properly per band, so an RGB image will # be (1,1,1). But, we don't support per band pixel types, # and anything more than one band is a uint8. So, just # take the first element. Revisit this if adding support # for more exotic images. - sampleFormat = (1,) + sample_format = (1,) bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) @@ -1244,29 +1475,52 @@ def _setup(self): else: bps_count = 1 bps_count += len(extra_tuple) - # Some files have only one value in bps_tuple, - # while should have more. Fix it - if bps_count > len(bps_tuple) and len(bps_tuple) == 1: - bps_tuple = bps_tuple * bps_count + bps_actual_count = len(bps_tuple) + samples_per_pixel = self.tag_v2.get( + SAMPLESPERPIXEL, + 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, + ) + + if samples_per_pixel > MAX_SAMPLESPERPIXEL: + # DOS check, samples_per_pixel can be a Long, and we extend the tuple below + logger.error( + "More samples per pixel than can be decoded: %s", samples_per_pixel + ) + msg = "Invalid value for samples per pixel" + raise SyntaxError(msg) + + if samples_per_pixel < bps_actual_count: + # If a file has more values in bps_tuple than expected, + # remove the excess. + bps_tuple = bps_tuple[:samples_per_pixel] + elif samples_per_pixel > bps_actual_count and bps_actual_count == 1: + # If a file has only one value in bps_tuple, when it should have more, + # presume it is the same number of bits for all of the samples. + bps_tuple = bps_tuple * samples_per_pixel + + if len(bps_tuple) != samples_per_pixel: + msg = "unknown data organization" + raise SyntaxError(msg) # mode: check photometric interpretation and bits per pixel key = ( self.tag_v2.prefix, photo, - sampleFormat, + sample_format, fillorder, bps_tuple, extra_tuple, ) - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) try: - self.mode, rawmode = OPEN_INFO[key] + self._mode, rawmode = OPEN_INFO[key] except KeyError as e: logger.debug("- unsupported format") - raise SyntaxError("unknown pixel mode") from e + msg = "unknown pixel mode" + raise SyntaxError(msg) from e - logger.debug(f"- raw mode: {rawmode}") - logger.debug(f"- pil mode: {self.mode}") + logger.debug("- raw mode: %s", rawmode) + logger.debug("- pil mode: %s", self.mode) self.info["compression"] = self._compression @@ -1276,11 +1530,11 @@ def _setup(self): if xres and yres: resunit = self.tag_v2.get(RESOLUTION_UNIT) if resunit == 2: # dots per inch - self.info["dpi"] = int(xres + 0.5), int(yres + 0.5) + self.info["dpi"] = (xres, yres) elif resunit == 3: # dots per centimeter. convert to dpi - self.info["dpi"] = int(xres * 2.54 + 0.5), int(yres * 2.54 + 0.5) + self.info["dpi"] = (xres * 2.54, yres * 2.54) elif resunit is None: # used to default to 1, but now 2) - self.info["dpi"] = int(xres + 0.5), int(yres + 0.5) + self.info["dpi"] = (xres, yres) # For backward compatibility, # we also preserve the old behavior self.info["resolution"] = xres, yres @@ -1307,38 +1561,48 @@ def _setup(self): if fillorder == 2: # Replace fillorder with fillorder=1 key = key[:3] + (1,) + key[4:] - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) # this should always work, since all the # fillorder==2 modes have a corresponding # fillorder=1 mode - self.mode, rawmode = OPEN_INFO[key] + self._mode, rawmode = OPEN_INFO[key] + # YCbCr images with new jpeg compression with pixels in one plane + # unpacked straight into RGB values + if ( + photo == 6 + and self._compression == "jpeg" + and self._planar_configuration == 1 + ): + rawmode = "RGB" # libtiff always returns the bytes in native order. # we're expecting image byte order. So, if the rawmode # contains I;16, we need to convert from native to image # byte order. - if rawmode == "I;16": + elif rawmode == "I;16": rawmode = "I;16N" - if ";16B" in rawmode: - rawmode = rawmode.replace(";16B", ";16N") - if ";16L" in rawmode: - rawmode = rawmode.replace(";16L", ";16N") + elif rawmode.endswith(";16B") or rawmode.endswith(";16L"): + rawmode = rawmode[:-1] + "N" # Offset in the tile tuple is 0, we go from 0,0 to # w,h, and we only do this once -- eds a = (rawmode, self._compression, False, self.tag_v2.offset) - self.tile.append(("libtiff", (0, 0, xsize, ysize), 0, a)) + self.tile.append(ImageFile._Tile("libtiff", (0, 0, xsize, ysize), 0, a)) elif STRIPOFFSETS in self.tag_v2 or TILEOFFSETS in self.tag_v2: # striped image if STRIPOFFSETS in self.tag_v2: offsets = self.tag_v2[STRIPOFFSETS] h = self.tag_v2.get(ROWSPERSTRIP, ysize) - w = self.size[0] + w = xsize else: # tiled image offsets = self.tag_v2[TILEOFFSETS] - w = self.tag_v2.get(322) - h = self.tag_v2.get(323) + tilewidth = self.tag_v2.get(TILEWIDTH) + h = self.tag_v2.get(TILELENGTH) + if not isinstance(tilewidth, int) or not isinstance(h, int): + msg = "Invalid tile dimensions" + raise ValueError(msg) + w = tilewidth for offset in offsets: if x + w > xsize: @@ -1353,24 +1617,25 @@ def _setup(self): # adjust stride width accordingly stride /= bps_count - a = (tile_rawmode, int(stride), 1) + args = (tile_rawmode, int(stride), 1) self.tile.append( - ( + ImageFile._Tile( self._compression, (x, y, min(x + w, xsize), min(y + h, ysize)), offset, - a, + args, ) ) x = x + w - if x >= self.size[0]: + if x >= xsize: x, y = 0, y + h - if y >= self.size[1]: + if y >= ysize: x = y = 0 layer += 1 else: logger.debug("- unsupported data organization") - raise SyntaxError("unknown data organization") + msg = "unknown data organization" + raise SyntaxError(msg) # Fix up info. if ICCPROFILE in self.tag_v2: @@ -1382,17 +1647,6 @@ def _setup(self): palette = [o8(b // 256) for b in self.tag_v2[COLORMAP]] self.palette = ImagePalette.raw("RGB;L", b"".join(palette)) - self._tile_orientation = self.tag_v2.get(0x0112) - - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # -------------------------------------------------------------------- @@ -1426,42 +1680,81 @@ def _close__fp(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TIFF") from e + msg = f"cannot write mode {im.mode} as TIFF" + raise OSError(msg) from e + + encoderinfo = im.encoderinfo + encoderconfig = im.encoderconfig ifd = ImageFileDirectory_v2(prefix=prefix) + if encoderinfo.get("big_tiff"): + ifd._bigtiff = True - compression = im.encoderinfo.get("compression", im.info.get("compression")) + try: + compression = encoderinfo["compression"] + except KeyError: + compression = im.info.get("compression") + if isinstance(compression, int): + # compression value may be from BMP. Ignore it + compression = None if compression is None: compression = "raw" elif compression == "tiff_jpeg": # OJPEG is obsolete, so use new-style JPEG compression instead compression = "jpeg" + elif compression == "tiff_deflate": + compression = "tiff_adobe_deflate" libtiff = WRITE_LIBTIFF or compression != "raw" # required for color libtiff images - ifd[PLANAR_CONFIGURATION] = getattr(im, "_planar_configuration", 1) + ifd[PLANAR_CONFIGURATION] = 1 ifd[IMAGEWIDTH] = im.size[0] ifd[IMAGELENGTH] = im.size[1] # write any arbitrary tags passed in as an ImageFileDirectory - info = im.encoderinfo.get("tiffinfo", {}) - logger.debug("Tiffinfo Keys: %s" % list(info)) + if "tiffinfo" in encoderinfo: + info = encoderinfo["tiffinfo"] + elif "exif" in encoderinfo: + info = encoderinfo["exif"] + if isinstance(info, bytes): + exif = Image.Exif() + exif.load(info) + info = exif + else: + info = {} + logger.debug("Tiffinfo Keys: %s", list(info)) if isinstance(info, ImageFileDirectory_v1): info = info.to_v2() for key in info: - ifd[key] = info.get(key) + if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS: + ifd[key] = info.get_ifd(key) + else: + ifd[key] = info.get(key) try: ifd.tagtype[key] = info.tagtype[key] except Exception: pass # might not be an IFD. Might not have populated type + legacy_ifd = {} + if hasattr(im, "tag"): + legacy_ifd = im.tag.to_v2() + + supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + for tag in ( + # IFD offset that may not be correct in the saved image + EXIFIFD, + # Determined by the image format and should not be copied from legacy_ifd. + SAMPLEFORMAT, + ): + if tag in supplied_tags: + del supplied_tags[tag] + # additions written by Greg Couch, gregc@cgl.ucsf.edu # inspired by image-sig posting from Kevin Cazabon, kcazabon@home.com if hasattr(im, "tag_v2"): @@ -1475,13 +1768,20 @@ def _save(im, fp, filename): XMP, ): if key in im.tag_v2: - ifd[key] = im.tag_v2[key] - ifd.tagtype[key] = im.tag_v2.tagtype[key] + if key == IPTC_NAA_CHUNK and im.tag_v2.tagtype[key] not in ( + TiffTags.BYTE, + TiffTags.UNDEFINED, + ): + del supplied_tags[key] + else: + ifd[key] = im.tag_v2[key] + ifd.tagtype[key] = im.tag_v2.tagtype[key] # preserve ICC profile (should also work when saving other formats # which support profiles as TIFF) -- 2008-06-06 Florian Hoech - if "icc_profile" in im.info: - ifd[ICCPROFILE] = im.info["icc_profile"] + icc = encoderinfo.get("icc_profile", im.info.get("icc_profile")) + if icc: + ifd[ICCPROFILE] = icc for key, name in [ (IMAGEDESCRIPTION, "description"), @@ -1495,14 +1795,14 @@ def _save(im, fp, filename): (ARTIST, "artist"), (COPYRIGHT, "copyright"), ]: - if name in im.encoderinfo: - ifd[key] = im.encoderinfo[name] + if name in encoderinfo: + ifd[key] = encoderinfo[name] - dpi = im.encoderinfo.get("dpi") + dpi = encoderinfo.get("dpi") if dpi: ifd[RESOLUTION_UNIT] = 2 - ifd[X_RESOLUTION] = int(dpi[0] + 0.5) - ifd[Y_RESOLUTION] = int(dpi[1] + 0.5) + ifd[X_RESOLUTION] = dpi[0] + ifd[Y_RESOLUTION] = dpi[1] if bits != (1,): ifd[BITSPERSAMPLE] = bits @@ -1513,81 +1813,121 @@ def _save(im, fp, filename): if format != 1: ifd[SAMPLEFORMAT] = format - ifd[PHOTOMETRIC_INTERPRETATION] = photo + if PHOTOMETRIC_INTERPRETATION not in ifd: + ifd[PHOTOMETRIC_INTERPRETATION] = photo + elif im.mode in ("1", "L") and ifd[PHOTOMETRIC_INTERPRETATION] == 0: + if im.mode == "1": + inverted_im = im.copy() + px = inverted_im.load() + if px is not None: + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im + else: + im = ImageOps.invert(im) if im.mode in ["P", "PA"]: lut = im.im.getpalette("RGB", "RGB;L") - ifd[COLORMAP] = tuple(i8(v) * 256 for v in lut) + colormap = [] + colors = len(lut) // 3 + for i in range(3): + colormap += [v * 256 for v in lut[colors * i : colors * (i + 1)]] + colormap += [0] * (256 - colors) + ifd[COLORMAP] = colormap # data orientation - stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8) - ifd[ROWSPERSTRIP] = im.size[1] - strip_byte_counts = stride * im.size[1] - if strip_byte_counts >= 2 ** 16: + w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] + stride = len(bits) * ((w * bits[0] + 7) // 8) + if ROWSPERSTRIP not in ifd: + # aim for given strip size (64 KB by default) when using libtiff writer + if libtiff: + im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) + rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h) + else: + rows_per_strip = h + if rows_per_strip == 0: + rows_per_strip = 1 + ifd[ROWSPERSTRIP] = rows_per_strip + strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP] + strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP] + if strip_byte_counts >= 2**16: ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG - ifd[STRIPBYTECOUNTS] = strip_byte_counts - ifd[STRIPOFFSETS] = 0 # this is adjusted by IFD writer + ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( + stride * h - strip_byte_counts * (strips_per_image - 1), + ) + ifd[STRIPOFFSETS] = tuple( + range(0, strip_byte_counts * strips_per_image, strip_byte_counts) + ) # this is adjusted by IFD writer # no compression by default: ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) + if im.mode == "YCbCr": + for tag, default_value in { + YCBCRSUBSAMPLING: (1, 1), + REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), + }.items(): + ifd.setdefault(tag, default_value) + + blocklist = [TILEWIDTH, TILELENGTH, TILEOFFSETS, TILEBYTECOUNTS] if libtiff: - if "quality" in im.encoderinfo: - quality = im.encoderinfo["quality"] + if "quality" in encoderinfo: + quality = encoderinfo["quality"] if not isinstance(quality, int) or quality < 0 or quality > 100: - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) if compression != "jpeg": - raise ValueError( - "quality setting only supported for 'jpeg' compression" - ) + msg = "quality setting only supported for 'jpeg' compression" + raise ValueError(msg) ifd[JPEGQUALITY] = quality logger.debug("Saving using libtiff encoder") - logger.debug("Items: %s" % sorted(ifd.items())) + logger.debug("Items: %s", sorted(ifd.items())) _fp = 0 if hasattr(fp, "fileno"): try: fp.seek(0) - _fp = os.dup(fp.fileno()) + _fp = fp.fileno() except io.UnsupportedOperation: pass # optional types for non core tags types = {} - # SAMPLEFORMAT is determined by the image format and should not be copied - # from legacy_ifd. # STRIPOFFSETS and STRIPBYTECOUNTS are added by the library # based on the data in the strip. + # OSUBFILETYPE is deprecated. # The other tags expect arrays with a certain length (fixed or depending on # BITSPERSAMPLE, etc), passing arrays with a different length will result in # segfaults. Block these tags until we add extra validation. - blocklist = [ + # SUBIFD may also cause a segfault. + blocklist += [ + OSUBFILETYPE, REFERENCEBLACKWHITE, - SAMPLEFORMAT, STRIPBYTECOUNTS, STRIPOFFSETS, TRANSFERFUNCTION, + SUBIFD, ] - atts = {} # bits per sample is a single short in the tiff directory, not a list. - atts[BITSPERSAMPLE] = bits[0] + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. - legacy_ifd = {} - if hasattr(im, "tag"): - legacy_ifd = im.tag.to_v2() - for tag, value in itertools.chain( - ifd.items(), getattr(im, "tag_v2", {}).items(), legacy_ifd.items() - ): + for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): # Libtiff can only process certain core items without adding # them to the custom dictionary. # Custom items are supported for int, float, unicode, string and byte # values. Other types and tuples require a tagtype. if tag not in TiffTags.LIBTIFF_CORE: - if not Image.core.libtiff_support_custom_tags: + if not getattr(Image.core, "libtiff_support_custom_tags", False): continue - if tag in ifd.tagtype: + if tag in TiffTags.TAGS_V2_GROUPS: + types[tag] = TiffTags.LONG8 + elif tag in ifd.tagtype: types[tag] = ifd.tagtype[tag] elif not (isinstance(value, (int, float, str, bytes))): continue @@ -1603,7 +1943,10 @@ def _save(im, fp, filename): else: atts[tag] = value - logger.debug("Converted items: %s" % sorted(atts.items())) + if SAMPLEFORMAT in atts and len(atts[SAMPLEFORMAT]) == 1: + atts[SAMPLEFORMAT] = atts[SAMPLEFORMAT][0] + + logger.debug("Converted items: %s", sorted(atts.items())) # libtiff always expects the bytes in native order. # we're storing image byte order. So, if the rawmode @@ -1618,32 +1961,36 @@ def _save(im, fp, filename): tags = list(atts.items()) tags.sort() a = (rawmode, compression, _fp, filename, tags, types) - e = Image._getencoder(im.mode, "libtiff", a, im.encoderconfig) - e.setimage(im.im, (0, 0) + im.size) + encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) + encoder.setimage(im.im, (0, 0) + im.size) while True: - # undone, change to self.decodermaxblock: - l, s, d = e.encode(16 * 1024) + errcode, data = encoder.encode(ImageFile.MAXBLOCK)[1:] if not _fp: - fp.write(d) - if s: + fp.write(data) + if errcode: break - if s < 0: - raise OSError(f"encoder error {s} when writing image file") + if errcode < 0: + msg = f"encoder error {errcode} when writing image file" + raise OSError(msg) else: + for tag in blocklist: + del ifd[tag] offset = ifd.save(fp) ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))], ) # -- helper for multi-page save -- - if "_debug_multipage" in im.encoderinfo: + if "_debug_multipage" in encoderinfo: # just to access o32 and o16 (using correct byte order) - im._debug_multipage = ifd + setattr(im, "_debug_multipage", ifd) -class AppendingTiffWriter: +class AppendingTiffWriter(io.BytesIO): fieldSizes = [ 0, # None 1, # byte @@ -1658,216 +2005,236 @@ class AppendingTiffWriter: 8, # srational 4, # float 8, # double + 4, # ifd + 2, # unicode + 4, # complex + 8, # long8 ] - # StripOffsets = 273 - # FreeOffsets = 288 - # TileOffsets = 324 - # JPEGQTables = 519 - # JPEGDCTables = 520 - # JPEGACTables = 521 - Tags = {273, 288, 324, 519, 520, 521} - - def __init__(self, fn, new=False): - if hasattr(fn, "read"): - self.f = fn - self.close_fp = False - else: + Tags = { + 273, # StripOffsets + 288, # FreeOffsets + 324, # TileOffsets + 519, # JPEGQTables + 520, # JPEGDCTables + 521, # JPEGACTables + } + + def __init__(self, fn: StrOrBytesPath | IO[bytes], new: bool = False) -> None: + self.f: IO[bytes] + if is_path(fn): self.name = fn self.close_fp = True try: self.f = open(fn, "w+b" if new else "r+b") except OSError: self.f = open(fn, "w+b") + else: + self.f = cast(IO[bytes], fn) + self.close_fp = False self.beginning = self.f.tell() self.setup() - def setup(self): + def setup(self) -> None: # Reset everything. self.f.seek(self.beginning, os.SEEK_SET) - self.whereToWriteNewIFDOffset = None + self.whereToWriteNewIFDOffset: int | None = None self.offsetOfNewPage = 0 - self.IIMM = IIMM = self.f.read(4) - if not IIMM: + self.IIMM = iimm = self.f.read(4) + if not iimm: # empty file - first page self.isFirst = True return self.isFirst = False - if IIMM == b"II\x2a\x00": + if iimm == b"II\x2a\x00": self.setEndian("<") - elif IIMM == b"MM\x00\x2a": + elif iimm == b"MM\x00\x2a": self.setEndian(">") else: - raise RuntimeError("Invalid TIFF file header") + msg = "Invalid TIFF file header" + raise RuntimeError(msg) self.skipIFDs() self.goToEnd() - def finalize(self): + def finalize(self) -> None: if self.isFirst: return # fix offsets self.f.seek(self.offsetOfNewPage) - IIMM = self.f.read(4) - if not IIMM: - # raise RuntimeError("nothing written into new page") + iimm = self.f.read(4) + if not iimm: # Make it easy to finish a frame without committing to a new one. return - if IIMM != self.IIMM: - raise RuntimeError("IIMM of new page doesn't match IIMM of first page") + if iimm != self.IIMM: + msg = "IIMM of new page doesn't match IIMM of first page" + raise RuntimeError(msg) - IFDoffset = self.readLong() - IFDoffset += self.offsetOfNewPage + ifd_offset = self.readLong() + ifd_offset += self.offsetOfNewPage + assert self.whereToWriteNewIFDOffset is not None self.f.seek(self.whereToWriteNewIFDOffset) - self.writeLong(IFDoffset) - self.f.seek(IFDoffset) + self.writeLong(ifd_offset) + self.f.seek(ifd_offset) self.fixIFD() - def newFrame(self): + def newFrame(self) -> None: # Call this to finish a frame. self.finalize() self.setup() - def __enter__(self): + def __enter__(self) -> AppendingTiffWriter: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: if self.close_fp: self.close() - return False - def tell(self): + def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage - def seek(self, offset, whence=io.SEEK_SET): + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: + """ + :param offset: Distance to seek. + :param whence: Whether the distance is relative to the start, + end or current position. + :returns: The resulting position, relative to the start. + """ if whence == os.SEEK_SET: offset += self.offsetOfNewPage self.f.seek(offset, whence) return self.tell() - def goToEnd(self): + def goToEnd(self) -> None: self.f.seek(0, os.SEEK_END) pos = self.f.tell() # pad to 16 byte boundary - padBytes = 16 - pos % 16 - if 0 < padBytes < 16: - self.f.write(bytes(padBytes)) + pad_bytes = 16 - pos % 16 + if 0 < pad_bytes < 16: + self.f.write(bytes(pad_bytes)) self.offsetOfNewPage = self.f.tell() - def setEndian(self, endian): + def setEndian(self, endian: str) -> None: self.endian = endian - self.longFmt = self.endian + "L" - self.shortFmt = self.endian + "H" - self.tagFormat = self.endian + "HHL" + self.longFmt = f"{self.endian}L" + self.shortFmt = f"{self.endian}H" + self.tagFormat = f"{self.endian}HHL" - def skipIFDs(self): + def skipIFDs(self) -> None: while True: - IFDoffset = self.readLong() - if IFDoffset == 0: + ifd_offset = self.readLong() + if ifd_offset == 0: self.whereToWriteNewIFDOffset = self.f.tell() - 4 break - self.f.seek(IFDoffset) - numTags = self.readShort() - self.f.seek(numTags * 12, os.SEEK_CUR) + self.f.seek(ifd_offset) + num_tags = self.readShort() + self.f.seek(num_tags * 12, os.SEEK_CUR) - def write(self, data): + def write(self, data: Buffer, /) -> int: return self.f.write(data) - def readShort(self): - (value,) = struct.unpack(self.shortFmt, self.f.read(2)) + def _fmt(self, field_size: int) -> str: + try: + return {2: "H", 4: "L", 8: "Q"}[field_size] + except KeyError: + msg = "offset is not supported" + raise RuntimeError(msg) + + def _read(self, field_size: int) -> int: + (value,) = struct.unpack( + self.endian + self._fmt(field_size), self.f.read(field_size) + ) return value - def readLong(self): - (value,) = struct.unpack(self.longFmt, self.f.read(4)) - return value + def readShort(self) -> int: + return self._read(2) - def rewriteLastShortToLong(self, value): - self.f.seek(-2, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") + def readLong(self) -> int: + return self._read(4) + + @staticmethod + def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: + if bytes_written is not None and bytes_written != expected: + msg = f"wrote only {bytes_written} bytes but wanted {expected}" + raise RuntimeError(msg) - def rewriteLastShort(self, value): + def rewriteLastShortToLong(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.shortFmt, value)) - if bytesWritten is not None and bytesWritten != 2: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 2") - - def rewriteLastLong(self, value): - self.f.seek(-4, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") - - def writeShort(self, value): - bytesWritten = self.f.write(struct.pack(self.shortFmt, value)) - if bytesWritten is not None and bytesWritten != 2: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 2") - - def writeLong(self, value): - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") - - def close(self): + bytes_written = self.f.write(struct.pack(self.longFmt, value)) + self._verify_bytes_written(bytes_written, 4) + + def _rewriteLast(self, value: int, field_size: int) -> None: + self.f.seek(-field_size, os.SEEK_CUR) + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(field_size), value) + ) + self._verify_bytes_written(bytes_written, field_size) + + def rewriteLastShort(self, value: int) -> None: + return self._rewriteLast(value, 2) + + def rewriteLastLong(self, value: int) -> None: + return self._rewriteLast(value, 4) + + def writeShort(self, value: int) -> None: + bytes_written = self.f.write(struct.pack(self.shortFmt, value)) + self._verify_bytes_written(bytes_written, 2) + + def writeLong(self, value: int) -> None: + bytes_written = self.f.write(struct.pack(self.longFmt, value)) + self._verify_bytes_written(bytes_written, 4) + + def close(self) -> None: self.finalize() - self.f.close() + if self.close_fp: + self.f.close() - def fixIFD(self): - numTags = self.readShort() + def fixIFD(self) -> None: + num_tags = self.readShort() - for i in range(numTags): - tag, fieldType, count = struct.unpack(self.tagFormat, self.f.read(8)) + for i in range(num_tags): + tag, field_type, count = struct.unpack(self.tagFormat, self.f.read(8)) - fieldSize = self.fieldSizes[fieldType] - totalSize = fieldSize * count - isLocal = totalSize <= 4 - if not isLocal: - offset = self.readLong() - offset += self.offsetOfNewPage + field_size = self.fieldSizes[field_type] + total_size = field_size * count + is_local = total_size <= 4 + if not is_local: + offset = self.readLong() + self.offsetOfNewPage self.rewriteLastLong(offset) if tag in self.Tags: - curPos = self.f.tell() + cur_pos = self.f.tell() - if isLocal: - self.fixOffsets( - count, isShort=(fieldSize == 2), isLong=(fieldSize == 4) - ) - self.f.seek(curPos + 4) + if is_local: + self._fixOffsets(count, field_size) + self.f.seek(cur_pos + 4) else: self.f.seek(offset) - self.fixOffsets( - count, isShort=(fieldSize == 2), isLong=(fieldSize == 4) - ) - self.f.seek(curPos) + self._fixOffsets(count, field_size) + self.f.seek(cur_pos) - offset = curPos = None - - elif isLocal: + elif is_local: # skip the locally stored value that is not an offset self.f.seek(4, os.SEEK_CUR) - def fixOffsets(self, count, isShort=False, isLong=False): - if not isShort and not isLong: - raise RuntimeError("offset is neither short nor long") - + def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): - offset = self.readShort() if isShort else self.readLong() + offset = self._read(field_size) offset += self.offsetOfNewPage - if isShort and offset >= 65536: + if field_size == 2 and offset >= 65536: # offset is now too large - we must convert shorts to longs if count != 1: - raise RuntimeError("not implemented") # XXX TODO + msg = "not implemented" + raise RuntimeError(msg) # XXX TODO # simple case - the offset is just one and therefore it is # local (not referenced with another offset) @@ -1875,13 +2242,22 @@ def fixOffsets(self, count, isShort=False, isLong=False): self.f.seek(-10, os.SEEK_CUR) self.writeShort(TiffTags.LONG) # rewrite the type to LONG self.f.seek(8, os.SEEK_CUR) - elif isShort: - self.rewriteLastShort(offset) else: - self.rewriteLastLong(offset) + self._rewriteLast(offset, field_size) + + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: + if isShort: + field_size = 2 + elif isLong: + field_size = 4 + else: + field_size = 0 + return self._fixOffsets(count, field_size) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) diff --git a/dependencies/OSX/PIL/TiffTags.py b/dependencies/OSX/PIL/TiffTags.py index 796ff34..86adaa4 100644 --- a/dependencies/OSX/PIL/TiffTags.py +++ b/dependencies/OSX/PIL/TiffTags.py @@ -16,39 +16,63 @@ # This module provides constants and clear-text names for various # well-known TIFF tags. ## +from __future__ import annotations -from collections import namedtuple +from typing import NamedTuple -class TagInfo(namedtuple("_TagInfo", "value name type length enum")): - __slots__ = [] +class _TagInfo(NamedTuple): + value: int | None + name: str + type: int | None + length: int | None + enum: dict[str, int] - def __new__(cls, value=None, name="unknown", type=None, length=None, enum=None): + +class TagInfo(_TagInfo): + __slots__: list[str] = [] + + def __new__( + cls, + value: int | None = None, + name: str = "unknown", + type: int | None = None, + length: int | None = None, + enum: dict[str, int] | None = None, + ) -> TagInfo: return super().__new__(cls, value, name, type, length, enum or {}) - def cvt_enum(self, value): + def cvt_enum(self, value: str) -> int | str: # Using get will call hash(value), which can be expensive # for some types (e.g. Fraction). Since self.enum is rarely # used, it's usually better to test it first. return self.enum.get(value, value) if self.enum else value -def lookup(tag): +def lookup(tag: int, group: int | None = None) -> TagInfo: """ :param tag: Integer tag number - :returns: Taginfo namedtuple, From the TAGS_V2 info if possible, - otherwise just populating the value and name from TAGS. + :param group: Which :py:data:`~PIL.TiffTags.TAGS_V2_GROUPS` to look in + + .. versionadded:: 8.3.0 + + :returns: Taginfo namedtuple, From the ``TAGS_V2`` info if possible, + otherwise just populating the value and name from ``TAGS``. If the tag is not recognized, "unknown" is returned for the name """ - return TAGS_V2.get(tag, TagInfo(tag, TAGS.get(tag, "unknown"))) + if group is not None: + info = TAGS_V2_GROUPS[group].get(tag) if group in TAGS_V2_GROUPS else None + else: + info = TAGS_V2.get(tag) + return info or TagInfo(tag, TAGS.get(tag, "unknown")) ## # Map tag numbers to tag info. # -# id: (Name, Type, Length, enum_values) +# id: (Name, Type, Length[, enum_values]) # # The length here differs from the length in the tiff spec. For # numbers, the tiff spec is for the number of fields returned. We @@ -70,8 +94,9 @@ def lookup(tag): FLOAT = 11 DOUBLE = 12 IFD = 13 +LONG8 = 16 -TAGS_V2 = { +_tags_v2: dict[int, tuple[str, int, int] | tuple[str, int, int, dict[str, int]]] = { 254: ("NewSubfileType", LONG, 1), 255: ("SubfileType", SHORT, 1), 256: ("ImageWidth", LONG, 1), @@ -151,6 +176,7 @@ def lookup(tag): 323: ("TileLength", LONG, 1), 324: ("TileOffsets", LONG, 0), 325: ("TileByteCounts", LONG, 0), + 330: ("SubIFDs", LONG, 0), 332: ("InkSet", SHORT, 1), 333: ("InkNames", ASCII, 1), 334: ("NumberOfInks", SHORT, 1), @@ -178,12 +204,16 @@ def lookup(tag): 532: ("ReferenceBlackWhite", RATIONAL, 6), 700: ("XMP", BYTE, 0), 33432: ("Copyright", ASCII, 1), - 33723: ("IptcNaaInfo", UNDEFINED, 0), + 33723: ("IptcNaaInfo", UNDEFINED, 1), 34377: ("PhotoshopInfo", BYTE, 0), # FIXME add more tags here 34665: ("ExifIFD", LONG, 1), 34675: ("ICCProfile", UNDEFINED, 1), 34853: ("GPSInfoIFD", LONG, 1), + 36864: ("ExifVersion", UNDEFINED, 1), + 37724: ("ImageSourceData", UNDEFINED, 1), + 40965: ("InteroperabilityIFD", LONG, 1), + 41730: ("CFAPattern", UNDEFINED, 1), # MPInfo 45056: ("MPFVersion", UNDEFINED, 1), 45057: ("NumberOfImages", LONG, 1), @@ -204,15 +234,61 @@ def lookup(tag): 45579: ("YawAngle", SIGNED_RATIONAL, 1), 45580: ("PitchAngle", SIGNED_RATIONAL, 1), 45581: ("RollAngle", SIGNED_RATIONAL, 1), + 40960: ("FlashPixVersion", UNDEFINED, 1), 50741: ("MakerNoteSafety", SHORT, 1, {"Unsafe": 0, "Safe": 1}), 50780: ("BestQualityScale", RATIONAL, 1), 50838: ("ImageJMetaDataByteCounts", LONG, 0), # Can be more than one 50839: ("ImageJMetaData", UNDEFINED, 1), # see Issue #2006 } +_tags_v2_groups = { + # ExifIFD + 34665: { + 36864: ("ExifVersion", UNDEFINED, 1), + 40960: ("FlashPixVersion", UNDEFINED, 1), + 40965: ("InteroperabilityIFD", LONG, 1), + 41730: ("CFAPattern", UNDEFINED, 1), + }, + # GPSInfoIFD + 34853: { + 0: ("GPSVersionID", BYTE, 4), + 1: ("GPSLatitudeRef", ASCII, 2), + 2: ("GPSLatitude", RATIONAL, 3), + 3: ("GPSLongitudeRef", ASCII, 2), + 4: ("GPSLongitude", RATIONAL, 3), + 5: ("GPSAltitudeRef", BYTE, 1), + 6: ("GPSAltitude", RATIONAL, 1), + 7: ("GPSTimeStamp", RATIONAL, 3), + 8: ("GPSSatellites", ASCII, 0), + 9: ("GPSStatus", ASCII, 2), + 10: ("GPSMeasureMode", ASCII, 2), + 11: ("GPSDOP", RATIONAL, 1), + 12: ("GPSSpeedRef", ASCII, 2), + 13: ("GPSSpeed", RATIONAL, 1), + 14: ("GPSTrackRef", ASCII, 2), + 15: ("GPSTrack", RATIONAL, 1), + 16: ("GPSImgDirectionRef", ASCII, 2), + 17: ("GPSImgDirection", RATIONAL, 1), + 18: ("GPSMapDatum", ASCII, 0), + 19: ("GPSDestLatitudeRef", ASCII, 2), + 20: ("GPSDestLatitude", RATIONAL, 3), + 21: ("GPSDestLongitudeRef", ASCII, 2), + 22: ("GPSDestLongitude", RATIONAL, 3), + 23: ("GPSDestBearingRef", ASCII, 2), + 24: ("GPSDestBearing", RATIONAL, 1), + 25: ("GPSDestDistanceRef", ASCII, 2), + 26: ("GPSDestDistance", RATIONAL, 1), + 27: ("GPSProcessingMethod", UNDEFINED, 0), + 28: ("GPSAreaInformation", UNDEFINED, 0), + 29: ("GPSDateStamp", ASCII, 11), + 30: ("GPSDifferential", SHORT, 1), + }, + # InteroperabilityIFD + 40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)}, +} # Legacy Tags structure # these tags aren't included above, but were in the previous versions -TAGS = { +TAGS: dict[int | tuple[int, int], str] = { 347: "JPEGTables", 700: "XMP", # Additional Exif Info @@ -253,7 +329,7 @@ def lookup(tag): 34910: "HylaFAX FaxRecvTime", 36864: "ExifVersion", 36867: "DateTimeOriginal", - 36868: "DateTImeDigitized", + 36868: "DateTimeDigitized", 37121: "ComponentsConfiguration", 37122: "CompressedBitsPerPixel", 37724: "ImageSourceData", @@ -356,9 +432,12 @@ def lookup(tag): 50784: "Alias Layer Metadata", } +TAGS_V2: dict[int, TagInfo] = {} +TAGS_V2_GROUPS: dict[int, dict[int, TagInfo]] = {} + -def _populate(): - for k, v in TAGS_V2.items(): +def _populate() -> None: + for k, v in _tags_v2.items(): # Populate legacy structure. TAGS[k] = v[0] if len(v) == 4: @@ -367,28 +446,15 @@ def _populate(): TAGS_V2[k] = TagInfo(k, *v) + for group, tags in _tags_v2_groups.items(): + TAGS_V2_GROUPS[group] = {k: TagInfo(k, *v) for k, v in tags.items()} + _populate() ## # Map type numbers to type names -- defined in ImageFileDirectory. -TYPES = {} - -# was: -# TYPES = { -# 1: "byte", -# 2: "ascii", -# 3: "short", -# 4: "long", -# 5: "rational", -# 6: "signed byte", -# 7: "undefined", -# 8: "signed short", -# 9: "signed long", -# 10: "signed rational", -# 11: "float", -# 12: "double", -# } +TYPES: dict[int, str] = {} # # These tags are handled by default in libtiff, without @@ -484,9 +550,6 @@ def _populate(): 65537, } -LIBTIFF_CORE.remove(301) # Array of short, crashes -LIBTIFF_CORE.remove(532) # Array of long, crashes - LIBTIFF_CORE.remove(255) # We don't have support for subfiletypes LIBTIFF_CORE.remove(322) # We don't have support for writing tiled images with libtiff LIBTIFF_CORE.remove(323) # Tiled images diff --git a/dependencies/OSX/PIL/WalImageFile.py b/dependencies/OSX/PIL/WalImageFile.py index b578d69..87e3287 100644 --- a/dependencies/OSX/PIL/WalImageFile.py +++ b/dependencies/OSX/PIL/WalImageFile.py @@ -22,55 +22,56 @@ is not registered for use with :py:func:`PIL.Image.open()`. To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. """ +from __future__ import annotations -import builtins +from typing import IO -from . import Image +from . import Image, ImageFile from ._binary import i32le as i32 +from ._typing import StrOrBytesPath -def open(filename): - """ - Load texture from a Quake2 WAL texture file. +class WalImageFile(ImageFile.ImageFile): + format = "WAL" + format_description = "Quake2 Texture" - By default, a Quake2 standard palette is attached to the texture. - To override the palette, use the :py:func:`PIL.Image.Image.putpalette()` method. + def _open(self) -> None: + self._mode = "P" - :param filename: WAL file name, or an opened file handle. - :returns: An image instance. - """ - # FIXME: modify to return a WalImageFile instance instead of - # plain Image object ? - - def imopen(fp): # read header fields - header = fp.read(32 + 24 + 32 + 12) - size = i32(header, 32), i32(header, 36) - offset = i32(header, 40) + header = self.fp.read(32 + 24 + 32 + 12) + self._size = i32(header, 32), i32(header, 36) + Image._decompression_bomb_check(self.size) # load pixel data - fp.seek(offset) - - Image._decompression_bomb_check(size) - im = Image.frombytes("P", size, fp.read(size[0] * size[1])) - im.putpalette(quake2palette) - - im.format = "WAL" - im.format_description = "Quake2 Texture" + offset = i32(header, 40) + self.fp.seek(offset) # strings are null-terminated - im.info["name"] = header[:32].split(b"\0", 1)[0] + self.info["name"] = header[:32].split(b"\0", 1)[0] next_name = header[56 : 56 + 32].split(b"\0", 1)[0] if next_name: - im.info["next_name"] = next_name + self.info["next_name"] = next_name + + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self.size[0] * self.size[1])) + self.putpalette(quake2palette) + return Image.Image.load(self) + - return im +def open(filename: StrOrBytesPath | IO[bytes]) -> WalImageFile: + """ + Load texture from a Quake2 WAL texture file. - if hasattr(filename, "read"): - return imopen(filename) - else: - with builtins.open(filename, "rb") as fp: - return imopen(fp) + By default, a Quake2 standard palette is attached to the texture. + To override the palette, use the :py:func:`PIL.Image.Image.putpalette()` method. + + :param filename: WAL file name, or an opened file handle. + :returns: An image instance. + """ + return WalImageFile(filename) quake2palette = ( diff --git a/dependencies/OSX/PIL/WebPImagePlugin.py b/dependencies/OSX/PIL/WebPImagePlugin.py index 2e9746f..c7f8555 100644 --- a/dependencies/OSX/PIL/WebPImagePlugin.py +++ b/dependencies/OSX/PIL/WebPImagePlugin.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from io import BytesIO +from typing import IO, Any from . import Image, ImageFile @@ -10,10 +13,6 @@ SUPPORTED = False -_VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True} - -_VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True} - _VP8_MODES_BY_IDENTIFIER = { b"VP8 ": "RGB", b"VP8X": "RGBA", @@ -21,7 +20,7 @@ } -def _accept(prefix): +def _accept(prefix: bytes) -> bool | str: is_riff_file_format = prefix[:4] == b"RIFF" is_webp_file = prefix[8:12] == b"WEBP" is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER @@ -32,32 +31,16 @@ def _accept(prefix): "image file could not be identified because WEBP support not installed" ) return True + return False class WebPImageFile(ImageFile.ImageFile): - format = "WEBP" format_description = "WebP image" __loaded = 0 __logical_frame = 0 - def _open(self): - if not _webp.HAVE_WEBPANIM: - # Legacy mode - data, width, height, self.mode, icc_profile, exif = _webp.WebPDecode( - self.fp.read() - ) - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - self._size = width, height - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] - self.n_frames = 1 - self.is_animated = False - return - + def _open(self) -> None: # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. self._decoder = _webp.WebPAnimDecoder(self.fp.read()) @@ -75,9 +58,8 @@ def _open(self): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self.mode = "RGB" if mode == "RGBX" else mode + self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode - self.tile = [] # Attempt to read ICC / EXIF / XMP chunks from file icc_profile = self._decoder.get_chunk("ICCP") @@ -93,26 +75,26 @@ def _open(self): # Initialize seek state self._reset(reset=False) - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: if "exif" not in self.info: return None - return dict(self.getexif()) + return self.getexif()._get_merged_dict() - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return # Set logical frame to requested position self.__logical_frame = frame - def _reset(self, reset=True): + def _reset(self, reset: bool = True) -> None: if reset: self._decoder.reset() self.__physical_frame = 0 self.__loaded = -1 self.__timestamp = 0 - def _get_next(self): + def _get_next(self) -> tuple[bytes, int, int]: # Get next frame ret = self._decoder.get_next() self.__physical_frame += 1 @@ -121,7 +103,8 @@ def _get_next(self): if ret is None: self._reset() # Reset just to be safe self.seek(0) - raise EOFError("failed to decode next frame in WebP file") + msg = "failed to decode next frame in WebP file" + raise EOFError(msg) # Compute duration data, timestamp = ret @@ -132,7 +115,7 @@ def _get_next(self): timestamp -= duration return data, timestamp, duration - def _seek(self, frame): + def _seek(self, frame: int) -> None: if self.__physical_frame == frame: return # Nothing to do if frame < self.__physical_frame: @@ -140,33 +123,39 @@ def _seek(self, frame): while self.__physical_frame < frame: self._get_next() # Advance to the requested frame - def load(self): - if _webp.HAVE_WEBPANIM: - if self.__loaded != self.__logical_frame: - self._seek(self.__logical_frame) + def load(self) -> Image.core.PixelAccess | None: + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) - # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration - self.__loaded = self.__logical_frame + # We need to load the image data for this frame + data, timestamp, duration = self._get_next() + self.info["timestamp"] = timestamp + self.info["duration"] = duration + self.__loaded = self.__logical_frame - # Set tile - if self.fp and self._exclusive_fp: - self.fp.close() - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] + # Set tile + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)] return super().load() - def tell(self): - if not _webp.HAVE_WEBPANIM: - return super().tell() + def load_seek(self, pos: int) -> None: + pass + def tell(self) -> int: return self.__logical_frame -def _save_all(im, fp, filename): +def _convert_frame(im: Image.Image) -> Image.Image: + # Make sure image mode is supported + if im.mode not in ("RGBX", "RGBA", "RGB"): + im = im.convert("RGBA" if im.has_transparency_data else "RGB") + return im + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -179,7 +168,7 @@ def _save_all(im, fp, filename): _save(im, fp, filename) return - background = (0, 0, 0, 0) + background: int | tuple[int, ...] = (0, 0, 0, 0) if "background" in encoderinfo: background = encoderinfo["background"] elif "background" in im.info: @@ -190,9 +179,11 @@ def _save_all(im, fp, filename): palette = im.getpalette() if palette: r, g, b = palette[background * 3 : (background + 1) * 3] - background = (r, g, b, 0) + background = (r, g, b, 255) + else: + background = (background, background, background, 255) - duration = im.encoderinfo.get("duration", 0) + duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) loop = im.encoderinfo.get("loop", 0) minimize_size = im.encoderinfo.get("minimize_size", False) kmin = im.encoderinfo.get("kmin", None) @@ -201,8 +192,9 @@ def _save_all(im, fp, filename): verbose = False lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) + alpha_quality = im.encoderinfo.get("alpha_quality", 100) method = im.encoderinfo.get("method", 0) - icc_profile = im.encoderinfo.get("icc_profile", "") + icc_profile = im.encoderinfo.get("icc_profile") or "" exif = im.encoderinfo.get("exif", "") if isinstance(exif, Image.Exif): exif = exif.tobytes() @@ -220,12 +212,10 @@ def _save_all(im, fp, filename): if ( not isinstance(background, (list, tuple)) or len(background) != 4 - or not all(v >= 0 and v < 256 for v in background) + or not all(0 <= v < 256 for v in background) ): - raise OSError( - "Background color is not an RGBA tuple clamped to (0-255): %s" - % str(background) - ) + msg = f"Background color is not an RGBA tuple clamped to (0-255): {background}" + raise OSError(msg) # Convert to packed uint bg_r, bg_g, bg_b, bg_a = background @@ -255,33 +245,16 @@ def _save_all(im, fp, filename): for idx in range(nfr): ims.seek(idx) - ims.load() - - # Make sure image mode is supported - frame = ims - rawmode = ims.mode - if ims.mode not in _VALID_WEBP_MODES: - alpha = ( - "A" in ims.mode - or "a" in ims.mode - or (ims.mode == "P" and "A" in ims.im.getpalettemode()) - ) - rawmode = "RGBA" if alpha else "RGB" - frame = ims.convert(rawmode) - - if rawmode == "RGB": - # For faster conversion, use RGBX - rawmode = "RGBX" + + frame = _convert_frame(ims) # Append the frame to the animation encoder enc.add( - frame.tobytes("raw", rawmode), - timestamp, - frame.size[0], - frame.size[1], - rawmode, + frame.getim(), + round(timestamp), lossless, quality, + alpha_quality, method, ) @@ -296,48 +269,47 @@ def _save_all(im, fp, filename): im.seek(cur_idx) # Force encoder to flush frames - enc.add(None, timestamp, 0, 0, "", lossless, quality, 0) + enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0) # Get the final output from the encoder data = enc.assemble(icc_profile, exif, xmp) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) - icc_profile = im.encoderinfo.get("icc_profile", "") - exif = im.encoderinfo.get("exif", "") + alpha_quality = im.encoderinfo.get("alpha_quality", 100) + icc_profile = im.encoderinfo.get("icc_profile") or "" + exif = im.encoderinfo.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] xmp = im.encoderinfo.get("xmp", "") - method = im.encoderinfo.get("method", 0) + method = im.encoderinfo.get("method", 4) + exact = 1 if im.encoderinfo.get("exact") else 0 - if im.mode not in _VALID_WEBP_LEGACY_MODES: - alpha = ( - "A" in im.mode - or "a" in im.mode - or (im.mode == "P" and "A" in im.im.getpalettemode()) - ) - im = im.convert("RGBA" if alpha else "RGB") + im = _convert_frame(im) data = _webp.WebPEncode( - im.tobytes(), - im.size[0], - im.size[1], + im.getim(), lossless, float(quality), - im.mode, + float(alpha_quality), icc_profile, method, + exact, exif, xmp, ) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) @@ -345,7 +317,6 @@ def _save(im, fp, filename): Image.register_open(WebPImageFile.format, WebPImageFile, _accept) if SUPPORTED: Image.register_save(WebPImageFile.format, _save) - if _webp.HAVE_WEBPANIM: - Image.register_save_all(WebPImageFile.format, _save_all) + Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/dependencies/OSX/PIL/WmfImagePlugin.py b/dependencies/OSX/PIL/WmfImagePlugin.py index 87847a1..48e9823 100644 --- a/dependencies/OSX/PIL/WmfImagePlugin.py +++ b/dependencies/OSX/PIL/WmfImagePlugin.py @@ -18,17 +18,19 @@ # https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-WMF/[MS-WMF].pdf # http://wvware.sourceforge.net/caolan/index.html # http://wvware.sourceforge.net/caolan/ora-wmf.html +from __future__ import annotations + +from typing import IO from . import Image, ImageFile from ._binary import i16le as word -from ._binary import i32le as dword from ._binary import si16le as short from ._binary import si32le as _long _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific WMF image handler. @@ -41,12 +43,12 @@ def register_handler(handler): if hasattr(Image.core, "drawwmf"): # install default handler (windows only) - class WmfHandler: - def open(self, im): - im.mode = "RGB" + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: + im._mode = "RGB" self.bbox = im.info["wmf_bbox"] - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: im.fp.seek(0) # rewind return Image.frombytes( "RGB", @@ -65,7 +67,7 @@ def load(self, im): # Read WMF file -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return ( prefix[:6] == b"\xd7\xcd\xc6\x9a\x00\x00" or prefix[:4] == b"\x01\x00\x00\x00" ) @@ -76,22 +78,23 @@ def _accept(prefix): class WmfStubImageFile(ImageFile.StubImageFile): - format = "WMF" format_description = "Windows Metafile" - def _open(self): + def _open(self) -> None: self._inch = None # check placable header s = self.fp.read(80) if s[:6] == b"\xd7\xcd\xc6\x9a\x00\x00": - # placeable windows metafile # get units per inch self._inch = word(s, 14) + if self._inch == 0: + msg = "Invalid inch" + raise ValueError(msg) # get bounding box x0 = short(s, 6) @@ -110,9 +113,10 @@ def _open(self): # sanity check (standard metafile header) if s[22:26] != b"\x01\x00\t\x00": - raise SyntaxError("Unsupported WMF file format") + msg = "Unsupported WMF file format" + raise SyntaxError(msg) - elif dword(s) == 1 and s[40:44] == b" EMF": + elif s[:4] == b"\x01\x00\x00\x00" and s[40:44] == b" EMF": # enhanced metafile # get bounding box @@ -127,8 +131,8 @@ def _open(self): size = x1 - x0, y1 - y0 # calculate dots per inch from bbox and frame - xdpi = int(2540.0 * (x1 - y0) / (frame[2] - frame[0]) + 0.5) - ydpi = int(2540.0 * (y1 - y0) / (frame[3] - frame[1]) + 0.5) + xdpi = 2540.0 * (x1 - x0) / (frame[2] - frame[0]) + ydpi = 2540.0 * (y1 - y0) / (frame[3] - frame[1]) self.info["wmf_bbox"] = x0, y0, x1, y1 @@ -138,32 +142,34 @@ def _open(self): self.info["dpi"] = xdpi, ydpi else: - raise SyntaxError("Unsupported file format") + msg = "Unsupported file format" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = size loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi=None): + def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: if dpi is not None and self._inch is not None: - self.info["dpi"] = int(dpi + 0.5) + self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] self._size = ( (x1 - x0) * self.info["dpi"] // self._inch, (y1 - y0) * self.info["dpi"] // self._inch, ) - super().load() + return super().load() -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): - raise OSError("WMF save handler not installed") + msg = "WMF save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/OSX/PIL/XVThumbImagePlugin.py b/dependencies/OSX/PIL/XVThumbImagePlugin.py index c0d8db0..7533335 100644 --- a/dependencies/OSX/PIL/XVThumbImagePlugin.py +++ b/dependencies/OSX/PIL/XVThumbImagePlugin.py @@ -16,9 +16,10 @@ # To do: # FIXME: make save work (this requires quantization support) # +from __future__ import annotations from . import Image, ImageFile, ImagePalette -from ._binary import i8, o8 +from ._binary import o8 _MAGIC = b"P7 332" @@ -32,7 +33,7 @@ ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] == _MAGIC @@ -41,15 +42,16 @@ def _accept(prefix): class XVThumbImageFile(ImageFile.ImageFile): - format = "XVThumb" format_description = "XV thumbnail image" - def _open(self): - + def _open(self) -> None: # check magic + assert self.fp is not None + if not _accept(self.fp.read(6)): - raise SyntaxError("not an XV thumbnail file") + msg = "not an XV thumbnail file" + raise SyntaxError(msg) # Skip to beginning of next line self.fp.readline() @@ -58,19 +60,22 @@ def _open(self): while True: s = self.fp.readline() if not s: - raise SyntaxError("Unexpected EOF reading XV thumbnail file") - if i8(s[0]) != 35: # ie. when not a comment: '#' + msg = "Unexpected EOF reading XV thumbnail file" + raise SyntaxError(msg) + if s[0] != 35: # ie. when not a comment: '#' break # parse header line (already read) s = s.strip().split() - self.mode = "P" + self._mode = "P" self._size = int(s[0]), int(s[1]) self.palette = ImagePalette.raw("RGB", PALETTE) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), self.mode) + ] # -------------------------------------------------------------------- diff --git a/dependencies/OSX/PIL/XbmImagePlugin.py b/dependencies/OSX/PIL/XbmImagePlugin.py index 644cfb3..943a044 100644 --- a/dependencies/OSX/PIL/XbmImagePlugin.py +++ b/dependencies/OSX/PIL/XbmImagePlugin.py @@ -18,24 +18,26 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from typing import IO from . import Image, ImageFile # XBM header xbm_head = re.compile( - br"\s*#define[ \t]+.*_width[ \t]+(?P[0-9]+)[\r\n]+" + rb"\s*#define[ \t]+.*_width[ \t]+(?P[0-9]+)[\r\n]+" b"#define[ \t]+.*_height[ \t]+(?P[0-9]+)[\r\n]+" b"(?P" b"#define[ \t]+[^_]*_x_hot[ \t]+(?P[0-9]+)[\r\n]+" b"#define[ \t]+[^_]*_y_hot[ \t]+(?P[0-9]+)[\r\n]+" b")?" - b"[\\000-\\377]*_bits\\[\\]" + rb"[\000-\377]*_bits\[]" ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix.lstrip()[:7] == b"#define" @@ -44,32 +46,34 @@ def _accept(prefix): class XbmImageFile(ImageFile.ImageFile): - format = "XBM" format_description = "X11 Bitmap" - def _open(self): + def _open(self) -> None: + assert self.fp is not None m = xbm_head.match(self.fp.read(512)) - if m: - - xsize = int(m.group("width")) - ysize = int(m.group("height")) + if not m: + msg = "not a XBM file" + raise SyntaxError(msg) - if m.group("hotspot"): - self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) + xsize = int(m.group("width")) + ysize = int(m.group("height")) - self.mode = "1" - self._size = xsize, ysize + if m.group("hotspot"): + self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) - self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] + self._mode = "1" + self._size = xsize, ysize + self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end())] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as XBM") + msg = f"cannot write mode {im.mode} as XBM" + raise OSError(msg) fp.write(f"#define im_width {im.size[0]}\n".encode("ascii")) fp.write(f"#define im_height {im.size[1]}\n".encode("ascii")) @@ -81,7 +85,7 @@ def _save(im, fp, filename): fp.write(b"static char im_bits[] = {\n") - ImageFile._save(im, fp, [("xbm", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size)]) fp.write(b"};\n") diff --git a/dependencies/OSX/PIL/XpmImagePlugin.py b/dependencies/OSX/PIL/XpmImagePlugin.py index d8bd00a..b985aa5 100644 --- a/dependencies/OSX/PIL/XpmImagePlugin.py +++ b/dependencies/OSX/PIL/XpmImagePlugin.py @@ -13,18 +13,18 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re from . import Image, ImageFile, ImagePalette -from ._binary import i8, o8 +from ._binary import o8 # XPM header xpm_head = re.compile(b'"([0-9]*) ([0-9]*) ([0-9]*) ([0-9]*)') -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:9] == b"/* XPM */" @@ -33,20 +33,20 @@ def _accept(prefix): class XpmImageFile(ImageFile.ImageFile): - format = "XPM" format_description = "X11 Pixel Map" - def _open(self): - + def _open(self) -> None: if not _accept(self.fp.read(9)): - raise SyntaxError("not an XPM file") + msg = "not an XPM file" + raise SyntaxError(msg) # skip forward to next string while True: s = self.fp.readline() if not s: - raise SyntaxError("broken XPM file") + msg = "broken XPM file" + raise SyntaxError(msg) m = xpm_head.match(s) if m: break @@ -57,33 +57,31 @@ def _open(self): bpp = int(m.group(4)) if pal > 256 or bpp != 1: - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) # # load palette description palette = [b"\0\0\0"] * 256 - for i in range(pal): - + for _ in range(pal): s = self.fp.readline() if s[-2:] == b"\r\n": s = s[:-2] elif s[-1:] in b"\r\n": s = s[:-1] - c = i8(s[1]) + c = s[1] s = s[2:-2].split() for i in range(0, len(s), 2): - if s[i] == b"c": - # process colour key rgb = s[i + 1] if rgb == b"None": self.info["transparency"] = c - elif rgb[0:1] == b"#": + elif rgb[:1] == b"#": # FIXME: handle colour names (see ImagePalette.py) rgb = int(rgb[1:], 16) palette[c] = ( @@ -91,30 +89,27 @@ def _open(self): ) else: # unknown colour - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) break else: - # missing colour key - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] - - def load_read(self, bytes): + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), "P")] + def load_read(self, read_bytes: int) -> bytes: # # load all image data in one chunk xsize, ysize = self.size - s = [None] * ysize - - for i in range(ysize): - s[i] = self.fp.readline()[1 : xsize + 1].ljust(xsize) + s = [self.fp.readline()[1 : xsize + 1].ljust(xsize) for i in range(ysize)] return b"".join(s) diff --git a/dependencies/OSX/PIL/__init__.py b/dependencies/OSX/PIL/__init__.py index d4f5ea7..09546fe 100644 --- a/dependencies/OSX/PIL/__init__.py +++ b/dependencies/OSX/PIL/__init__.py @@ -1,11 +1,11 @@ """Pillow (Fork of the Python Imaging Library) -Pillow is the friendly PIL fork by Alex Clark and Contributors. +Pillow is the friendly PIL fork by Jeffrey A. Clark and contributors. https://github.com/python-pillow/Pillow/ Pillow is forked from PIL 1.1.7. -PIL is the Python Imaging Library by Fredrik Lundh and Contributors. +PIL is the Python Imaging Library by Fredrik Lundh and contributors. Copyright (c) 1999 by Secret Labs AB. Use PIL.__version__ for this Pillow version. @@ -13,72 +13,14 @@ ;-) """ -import sys -import warnings +from __future__ import annotations from . import _version # VERSION was removed in Pillow 6.0.0. -__version__ = _version.__version__ - - -# PILLOW_VERSION is deprecated and will be removed in a future release. +# PILLOW_VERSION was removed in Pillow 9.0.0. # Use __version__ instead. -def _raise_version_warning(): - warnings.warn( - "PILLOW_VERSION is deprecated and will be removed in a future release. " - "Use __version__ instead.", - DeprecationWarning, - stacklevel=3, - ) - - -if sys.version_info >= (3, 7): - - def __getattr__(name): - if name == "PILLOW_VERSION": - _raise_version_warning() - return __version__ - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -else: - - class _Deprecated_Version(str): - def __str__(self): - _raise_version_warning() - return super().__str__() - - def __getitem__(self, key): - _raise_version_warning() - return super().__getitem__(key) - - def __eq__(self, other): - _raise_version_warning() - return super().__eq__(other) - - def __ne__(self, other): - _raise_version_warning() - return super().__ne__(other) - - def __gt__(self, other): - _raise_version_warning() - return super().__gt__(other) - - def __lt__(self, other): - _raise_version_warning() - return super().__lt__(other) - - def __ge__(self, other): - _raise_version_warning() - return super().__gt__(other) - - def __le__(self, other): - _raise_version_warning() - return super().__lt__(other) - - PILLOW_VERSION = _Deprecated_Version(__version__) - +__version__ = _version.__version__ del _version @@ -90,7 +32,7 @@ def __le__(self, other): "DcxImagePlugin", "DdsImagePlugin", "EpsImagePlugin", - "FitsStubImagePlugin", + "FitsImagePlugin", "FliImagePlugin", "FpxImagePlugin", "FtexImagePlugin", @@ -118,6 +60,7 @@ def __le__(self, other): "PngImagePlugin", "PpmImagePlugin", "PsdImagePlugin", + "QoiImagePlugin", "SgiImagePlugin", "SpiderImagePlugin", "SunImagePlugin", @@ -134,6 +77,10 @@ def __le__(self, other): class UnidentifiedImageError(OSError): """ Raised in :py:meth:`PIL.Image.open` if an image cannot be opened and identified. + + If a PNG image raises this error, setting :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` + to true may allow the image to be opened after all. The setting will ignore missing + data and checksum failures. """ pass diff --git a/dependencies/OSX/PIL/__main__.py b/dependencies/OSX/PIL/__main__.py index a05323f..043156e 100644 --- a/dependencies/OSX/PIL/__main__.py +++ b/dependencies/OSX/PIL/__main__.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import sys + from .features import pilinfo -pilinfo() +pilinfo(supported_formats="--report" not in sys.argv) diff --git a/dependencies/OSX/PIL/_binary.py b/dependencies/OSX/PIL/_binary.py index 5564f45..4594ccc 100644 --- a/dependencies/OSX/PIL/_binary.py +++ b/dependencies/OSX/PIL/_binary.py @@ -13,21 +13,21 @@ """Binary input/output support routines.""" - +from __future__ import annotations from struct import pack, unpack_from -def i8(c): - return c if c.__class__ is int else c[0] +def i8(c: bytes) -> int: + return c[0] -def o8(i): +def o8(i: int) -> bytes: return bytes((i & 255,)) # Input, le = little endian, be = big endian -def i16le(c, o=0): +def i16le(c: bytes, o: int = 0) -> int: """ Converts a 2-bytes (16 bits) string to an unsigned integer. @@ -37,7 +37,7 @@ def i16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer. @@ -47,7 +47,17 @@ def si16le(c, o=0): return unpack_from(" int: + """ + Converts a 2-bytes (16 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">h", c, o)[0] + + +def i32le(c: bytes, o: int = 0) -> int: """ Converts a 4-bytes (32 bits) string to an unsigned integer. @@ -57,7 +67,7 @@ def i32le(c, o=0): return unpack_from(" int: """ Converts a 4-bytes (32 bits) string to a signed integer. @@ -67,26 +77,36 @@ def si32le(c, o=0): return unpack_from(" int: + """ + Converts a 4-bytes (32 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">i", c, o)[0] + + +def i16be(c: bytes, o: int = 0) -> int: return unpack_from(">H", c, o)[0] -def i32be(c, o=0): +def i32be(c: bytes, o: int = 0) -> int: return unpack_from(">I", c, o)[0] # Output, le = little endian, be = big endian -def o16le(i): +def o16le(i: int) -> bytes: return pack(" bytes: return pack(" bytes: return pack(">H", i) -def o32be(i): +def o32be(i: int) -> bytes: return pack(">I", i) diff --git a/dependencies/OSX/PIL/_deprecate.py b/dependencies/OSX/PIL/_deprecate.py new file mode 100644 index 0000000..83952b3 --- /dev/null +++ b/dependencies/OSX/PIL/_deprecate.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import warnings + +from . import __version__ + + +def deprecate( + deprecated: str, + when: int | None, + replacement: str | None = None, + *, + action: str | None = None, + plural: bool = False, +) -> None: + """ + Deprecations helper. + + :param deprecated: Name of thing to be deprecated. + :param when: Pillow major version to be removed in. + :param replacement: Name of replacement. + :param action: Instead of "replacement", give a custom call to action + e.g. "Upgrade to new thing". + :param plural: if the deprecated thing is plural, needing "are" instead of "is". + + Usually of the form: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + Use [replacement] instead." + + You can leave out the replacement sentence: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd)" + + Or with another call to action: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + [action]." + """ + + is_ = "are" if plural else "is" + + if when is None: + removed = "a future version" + elif when <= int(__version__.split(".")[0]): + msg = f"{deprecated} {is_} deprecated and should be removed." + raise RuntimeError(msg) + elif when == 12: + removed = "Pillow 12 (2025-10-15)" + else: + msg = f"Unknown removal version: {when}. Update {__name__}?" + raise ValueError(msg) + + if replacement and action: + msg = "Use only one of 'replacement' and 'action'" + raise ValueError(msg) + + if replacement: + action = f". Use {replacement} instead." + elif action: + action = f". {action.rstrip('.')}." + else: + action = "" + + warnings.warn( + f"{deprecated} {is_} deprecated and will be removed in {removed}{action}", + DeprecationWarning, + stacklevel=3, + ) diff --git a/dependencies/OSX/PIL/_imaging.cpython-311-darwin.so b/dependencies/OSX/PIL/_imaging.cpython-311-darwin.so new file mode 100644 index 0000000..bcb5d3c Binary files /dev/null and b/dependencies/OSX/PIL/_imaging.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imaging.pyi b/dependencies/OSX/PIL/_imaging.pyi new file mode 100644 index 0000000..998bc52 --- /dev/null +++ b/dependencies/OSX/PIL/_imaging.pyi @@ -0,0 +1,31 @@ +from typing import Any + +class ImagingCore: + def __getitem__(self, index: int) -> float: ... + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: ... + def __setitem__( + self, xy: tuple[int, int], color: float | tuple[int, ...] + ) -> None: ... + +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_imaging.so b/dependencies/OSX/PIL/_imaging.so deleted file mode 100644 index 50eb801..0000000 Binary files a/dependencies/OSX/PIL/_imaging.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_imagingcms.cpython-311-darwin.so b/dependencies/OSX/PIL/_imagingcms.cpython-311-darwin.so new file mode 100644 index 0000000..92dcbd5 Binary files /dev/null and b/dependencies/OSX/PIL/_imagingcms.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imagingcms.pyi b/dependencies/OSX/PIL/_imagingcms.pyi new file mode 100644 index 0000000..ddcf93a --- /dev/null +++ b/dependencies/OSX/PIL/_imagingcms.pyi @@ -0,0 +1,143 @@ +import datetime +import sys +from typing import Literal, SupportsFloat, TypedDict + +from ._typing import CapsuleType + +littlecms_version: str | None + +_Tuple3f = tuple[float, float, float] +_Tuple2x3f = tuple[_Tuple3f, _Tuple3f] +_Tuple3x3f = tuple[_Tuple3f, _Tuple3f, _Tuple3f] + +class _IccMeasurementCondition(TypedDict): + observer: int + backing: _Tuple3f + geo: str + flare: float + illuminant_type: str + +class _IccViewingCondition(TypedDict): + illuminant: _Tuple3f + surround: _Tuple3f + illuminant_type: str + +class CmsProfile: + @property + def rendering_intent(self) -> int: ... + @property + def creation_date(self) -> datetime.datetime | None: ... + @property + def copyright(self) -> str | None: ... + @property + def target(self) -> str | None: ... + @property + def manufacturer(self) -> str | None: ... + @property + def model(self) -> str | None: ... + @property + def profile_description(self) -> str | None: ... + @property + def screening_description(self) -> str | None: ... + @property + def viewing_condition(self) -> str | None: ... + @property + def version(self) -> float: ... + @property + def icc_version(self) -> int: ... + @property + def attributes(self) -> int: ... + @property + def header_flags(self) -> int: ... + @property + def header_manufacturer(self) -> str: ... + @property + def header_model(self) -> str: ... + @property + def device_class(self) -> str: ... + @property + def connection_space(self) -> str: ... + @property + def xcolor_space(self) -> str: ... + @property + def profile_id(self) -> bytes: ... + @property + def is_matrix_shaper(self) -> bool: ... + @property + def technology(self) -> str | None: ... + @property + def colorimetric_intent(self) -> str | None: ... + @property + def perceptual_rendering_intent_gamut(self) -> str | None: ... + @property + def saturation_rendering_intent_gamut(self) -> str | None: ... + @property + def red_colorant(self) -> _Tuple2x3f | None: ... + @property + def green_colorant(self) -> _Tuple2x3f | None: ... + @property + def blue_colorant(self) -> _Tuple2x3f | None: ... + @property + def red_primary(self) -> _Tuple2x3f | None: ... + @property + def green_primary(self) -> _Tuple2x3f | None: ... + @property + def blue_primary(self) -> _Tuple2x3f | None: ... + @property + def media_white_point_temperature(self) -> float | None: ... + @property + def media_white_point(self) -> _Tuple2x3f | None: ... + @property + def media_black_point(self) -> _Tuple2x3f | None: ... + @property + def luminance(self) -> _Tuple2x3f | None: ... + @property + def chromatic_adaptation(self) -> tuple[_Tuple3x3f, _Tuple3x3f] | None: ... + @property + def chromaticity(self) -> _Tuple3x3f | None: ... + @property + def colorant_table(self) -> list[str] | None: ... + @property + def colorant_table_out(self) -> list[str] | None: ... + @property + def intent_supported(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def clut(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def icc_measurement_condition(self) -> _IccMeasurementCondition | None: ... + @property + def icc_viewing_condition(self) -> _IccViewingCondition | None: ... + def is_intent_supported(self, intent: int, direction: int, /) -> int: ... + +class CmsTransform: + def apply(self, id_in: CapsuleType, id_out: CapsuleType) -> int: ... + +def profile_open(profile: str, /) -> CmsProfile: ... +def profile_frombytes(profile: bytes, /) -> CmsProfile: ... +def profile_tobytes(profile: CmsProfile, /) -> bytes: ... +def buildTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def buildProofTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + proof_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + proof_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def createProfile( + color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: SupportsFloat = 0.0, / +) -> CmsProfile: ... + +if sys.platform == "win32": + def get_display_profile_win32(handle: int = 0, is_dc: int = 0, /) -> str | None: ... diff --git a/dependencies/OSX/PIL/_imagingcms.so b/dependencies/OSX/PIL/_imagingcms.so deleted file mode 100644 index 3b9b26d..0000000 Binary files a/dependencies/OSX/PIL/_imagingcms.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_imagingft.cpython-311-darwin.so b/dependencies/OSX/PIL/_imagingft.cpython-311-darwin.so new file mode 100644 index 0000000..9b4f661 Binary files /dev/null and b/dependencies/OSX/PIL/_imagingft.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imagingft.pyi b/dependencies/OSX/PIL/_imagingft.pyi new file mode 100644 index 0000000..9cc9822 --- /dev/null +++ b/dependencies/OSX/PIL/_imagingft.pyi @@ -0,0 +1,69 @@ +from typing import Any, Callable + +from . import ImageFont, _imaging + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + def render( + self, + string: str | bytes, + fill: Callable[[int, int], _imaging.ImagingCore], + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + stroke_width: float, + anchor: str | None, + foreground_ink_long: int, + x_start: float, + y_start: float, + /, + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... + def getsize( + self, + string: str | bytes | bytearray, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + anchor: str | None, + /, + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, + string: str | bytes, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + /, + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[ImageFont.Axis]: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont( + filename: str | bytes, + size: float, + index: int, + encoding: str, + font_bytes: bytes, + layout_engine: int, +) -> Font: ... +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_imagingft.so b/dependencies/OSX/PIL/_imagingft.so deleted file mode 100644 index c71c429..0000000 Binary files a/dependencies/OSX/PIL/_imagingft.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_imagingmath.cpython-311-darwin.so b/dependencies/OSX/PIL/_imagingmath.cpython-311-darwin.so new file mode 100644 index 0000000..a16886c Binary files /dev/null and b/dependencies/OSX/PIL/_imagingmath.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imagingmath.pyi b/dependencies/OSX/PIL/_imagingmath.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/OSX/PIL/_imagingmath.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_imagingmath.so b/dependencies/OSX/PIL/_imagingmath.so deleted file mode 100644 index c7984d4..0000000 Binary files a/dependencies/OSX/PIL/_imagingmath.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_imagingmorph.cpython-311-darwin.so b/dependencies/OSX/PIL/_imagingmorph.cpython-311-darwin.so new file mode 100644 index 0000000..f869aa8 Binary files /dev/null and b/dependencies/OSX/PIL/_imagingmorph.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imagingmorph.pyi b/dependencies/OSX/PIL/_imagingmorph.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/OSX/PIL/_imagingmorph.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_imagingmorph.so b/dependencies/OSX/PIL/_imagingmorph.so deleted file mode 100644 index aef44f2..0000000 Binary files a/dependencies/OSX/PIL/_imagingmorph.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_imagingtk.cpython-311-darwin.so b/dependencies/OSX/PIL/_imagingtk.cpython-311-darwin.so new file mode 100644 index 0000000..be2512a Binary files /dev/null and b/dependencies/OSX/PIL/_imagingtk.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_imagingtk.pyi b/dependencies/OSX/PIL/_imagingtk.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/OSX/PIL/_imagingtk.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_imagingtk.so b/dependencies/OSX/PIL/_imagingtk.so deleted file mode 100644 index 1e0f9bc..0000000 Binary files a/dependencies/OSX/PIL/_imagingtk.so and /dev/null differ diff --git a/dependencies/OSX/PIL/_tkinter_finder.py b/dependencies/OSX/PIL/_tkinter_finder.py index 7018a1b..beddfb0 100644 --- a/dependencies/OSX/PIL/_tkinter_finder.py +++ b/dependencies/OSX/PIL/_tkinter_finder.py @@ -1,9 +1,21 @@ """ Find compiled module linking to Tcl / Tk libraries """ + +from __future__ import annotations + import sys -from tkinter import _tkinter as tk +import tkinter + +tk = getattr(tkinter, "_tkinter") + +try: + if hasattr(sys, "pypy_find_executable"): + TKINTER_LIB = tk.tklib_cffi.__file__ + else: + TKINTER_LIB = tk.__file__ +except AttributeError: + # _tkinter may be compiled directly into Python, in which case __file__ is + # not available. load_tkinter_funcs will check the binary first in any case. + TKINTER_LIB = None -if hasattr(sys, "pypy_find_executable"): - TKINTER_LIB = tk.tklib_cffi.__file__ -else: - TKINTER_LIB = tk.__file__ +tk_version = str(tkinter.TkVersion) diff --git a/dependencies/OSX/PIL/_typing.py b/dependencies/OSX/PIL/_typing.py new file mode 100644 index 0000000..34a9a81 --- /dev/null +++ b/dependencies/OSX/PIL/_typing.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union + +if TYPE_CHECKING: + from numbers import _IntegralLike as IntegralLike + + try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] # requires numpy>=1.21 + except (ImportError, AttributeError): + pass + +if sys.version_info >= (3, 13): + from types import CapsuleType +else: + CapsuleType = object + +if sys.version_info >= (3, 12): + from collections.abc import Buffer +else: + Buffer = Any + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + try: + from typing_extensions import TypeGuard + except ImportError: + + class TypeGuard: # type: ignore[no-redef] + def __class_getitem__(cls, item: Any) -> type[bool]: + return bool + + +Coords = Union[Sequence[float], Sequence[Sequence[float]]] + + +_T_co = TypeVar("_T_co", covariant=True) + + +class SupportsRead(Protocol[_T_co]): + def read(self, length: int = ..., /) -> _T_co: ... + + +StrOrBytesPath = Union[str, bytes, os.PathLike[str], os.PathLike[bytes]] + + +__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] diff --git a/dependencies/OSX/PIL/_util.py b/dependencies/OSX/PIL/_util.py index 0c5d389..8ef0d36 100644 --- a/dependencies/OSX/PIL/_util.py +++ b/dependencies/OSX/PIL/_util.py @@ -1,19 +1,26 @@ -import os -from pathlib import Path +from __future__ import annotations +import os +from typing import Any, NoReturn -def isPath(f): - return isinstance(f, (bytes, str, Path)) +from ._typing import StrOrBytesPath, TypeGuard -# Checks if an object is a string, and that it points to a directory. -def isDirectory(f): - return isPath(f) and os.path.isdir(f) +def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: + return isinstance(f, (bytes, str, os.PathLike)) -class deferred_error: - def __init__(self, ex): +class DeferredError: + def __init__(self, ex: BaseException): self.ex = ex - def __getattr__(self, elt): + def __getattr__(self, elt: str) -> NoReturn: raise self.ex + + @staticmethod + def new(ex: BaseException) -> Any: + """ + Creates an object that raises the wrapped exception ``ex`` when used, + and casts it to :py:obj:`~typing.Any` type. + """ + return DeferredError(ex) diff --git a/dependencies/OSX/PIL/_version.py b/dependencies/OSX/PIL/_version.py index 51fe4de..9938a0a 100644 --- a/dependencies/OSX/PIL/_version.py +++ b/dependencies/OSX/PIL/_version.py @@ -1,2 +1,4 @@ # Master version for Pillow -__version__ = "8.0.1" +from __future__ import annotations + +__version__ = "11.1.0" diff --git a/dependencies/OSX/PIL/_webp.cpython-311-darwin.so b/dependencies/OSX/PIL/_webp.cpython-311-darwin.so new file mode 100644 index 0000000..68fa810 Binary files /dev/null and b/dependencies/OSX/PIL/_webp.cpython-311-darwin.so differ diff --git a/dependencies/OSX/PIL/_webp.pyi b/dependencies/OSX/PIL/_webp.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/OSX/PIL/_webp.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/OSX/PIL/_webp.so b/dependencies/OSX/PIL/_webp.so deleted file mode 100644 index 3175b6d..0000000 Binary files a/dependencies/OSX/PIL/_webp.so and /dev/null differ diff --git a/dependencies/OSX/PIL/features.py b/dependencies/OSX/PIL/features.py index da0ca55..3645e3d 100644 --- a/dependencies/OSX/PIL/features.py +++ b/dependencies/OSX/PIL/features.py @@ -1,22 +1,26 @@ +from __future__ import annotations + import collections import os import sys import warnings +from typing import IO import PIL from . import Image +from ._deprecate import deprecate modules = { "pil": ("PIL._imaging", "PILLOW_VERSION"), - "tkinter": ("PIL._tkinter_finder", None), + "tkinter": ("PIL._tkinter_finder", "tk_version"), "freetype2": ("PIL._imagingft", "freetype2_version"), "littlecms2": ("PIL._imagingcms", "littlecms_version"), "webp": ("PIL._webp", "webpdecoder_version"), } -def check_module(feature): +def check_module(feature: str) -> bool: """ Checks if a module is available. @@ -24,19 +28,23 @@ def check_module(feature): :returns: ``True`` if available, ``False`` otherwise. :raises ValueError: If the module is not defined in this version of Pillow. """ - if not (feature in modules): - raise ValueError(f"Unknown module {feature}") + if feature not in modules: + msg = f"Unknown module {feature}" + raise ValueError(msg) module, ver = modules[feature] try: __import__(module) return True - except ImportError: + except ModuleNotFoundError: + return False + except ImportError as ex: + warnings.warn(str(ex)) return False -def version_module(feature): +def version_module(feature: str) -> str | None: """ :param feature: The module to check for. :returns: @@ -48,13 +56,10 @@ def version_module(feature): module, ver = modules[feature] - if ver is None: - return None - return getattr(__import__(module, fromlist=[ver]), ver) -def get_supported_modules(): +def get_supported_modules() -> list[str]: """ :returns: A list of all supported modules. """ @@ -69,7 +74,7 @@ def get_supported_modules(): } -def check_codec(feature): +def check_codec(feature: str) -> bool: """ Checks if a codec is available. @@ -78,14 +83,15 @@ def check_codec(feature): :raises ValueError: If the codec is not defined in this version of Pillow. """ if feature not in codecs: - raise ValueError(f"Unknown codec {feature}") + msg = f"Unknown codec {feature}" + raise ValueError(msg) codec, lib = codecs[feature] - return codec + "_encoder" in dir(Image.core) + return f"{codec}_encoder" in dir(Image.core) -def version_codec(feature): +def version_codec(feature: str) -> str | None: """ :param feature: The codec to check for. :returns: @@ -98,7 +104,7 @@ def version_codec(feature): codec, lib = codecs[feature] - version = getattr(Image.core, lib + "_version") + version = getattr(Image.core, f"{lib}_version") if feature == "libtiff": return version.split("\n")[0].split("Version ")[1] @@ -106,25 +112,28 @@ def version_codec(feature): return version -def get_supported_codecs(): +def get_supported_codecs() -> list[str]: """ :returns: A list of all supported codecs. """ return [f for f in codecs if check_codec(f)] -features = { - "webp_anim": ("PIL._webp", "HAVE_WEBPANIM", None), - "webp_mux": ("PIL._webp", "HAVE_WEBPMUX", None), - "transp_webp": ("PIL._webp", "HAVE_TRANSPARENCY", None), +features: dict[str, tuple[str, str | bool, str | None]] = { + "webp_anim": ("PIL._webp", True, None), + "webp_mux": ("PIL._webp", True, None), + "transp_webp": ("PIL._webp", True, None), "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), + "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), + "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), "libjpeg_turbo": ("PIL._imaging", "HAVE_LIBJPEGTURBO", "libjpeg_turbo_version"), + "zlib_ng": ("PIL._imaging", "HAVE_ZLIBNG", "zlib_ng_version"), "libimagequant": ("PIL._imaging", "HAVE_LIBIMAGEQUANT", "imagequant_version"), "xcb": ("PIL._imaging", "HAVE_XCB", None), } -def check_feature(feature): +def check_feature(feature: str) -> bool | None: """ Checks if a feature is available. @@ -133,18 +142,26 @@ def check_feature(feature): :raises ValueError: If the feature is not defined in this version of Pillow. """ if feature not in features: - raise ValueError(f"Unknown feature {feature}") + msg = f"Unknown feature {feature}" + raise ValueError(msg) module, flag, ver = features[feature] + if isinstance(flag, bool): + deprecate(f'check_feature("{feature}")', 12) try: imported_module = __import__(module, fromlist=["PIL"]) + if isinstance(flag, bool): + return flag return getattr(imported_module, flag) - except ImportError: + except ModuleNotFoundError: + return None + except ImportError as ex: + warnings.warn(str(ex)) return None -def version_feature(feature): +def version_feature(feature: str) -> str | None: """ :param feature: The feature to check for. :returns: The version number as a string, or ``None`` if not available. @@ -161,14 +178,24 @@ def version_feature(feature): return getattr(__import__(module, fromlist=[ver]), ver) -def get_supported_features(): +def get_supported_features() -> list[str]: """ :returns: A list of all supported features. """ - return [f for f in features if check_feature(f)] - - -def check(feature): + supported_features = [] + for f, (module, flag, _) in features.items(): + if flag is True: + for feature, (feature_module, _) in modules.items(): + if feature_module == module: + if check_module(feature): + supported_features.append(f) + break + elif check_feature(f): + supported_features.append(f) + return supported_features + + +def check(feature: str) -> bool | None: """ :param feature: A module, codec, or feature name. :returns: @@ -186,7 +213,7 @@ def check(feature): return False -def version(feature): +def version(feature: str) -> str | None: """ :param feature: The module, codec, or feature to check for. @@ -202,7 +229,7 @@ def version(feature): return None -def get_supported(): +def get_supported() -> list[str]: """ :returns: A list of all supported modules, features, and codecs. """ @@ -213,10 +240,13 @@ def get_supported(): return ret -def pilinfo(out=None, supported_formats=True): +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. - This function can be called with ``python -m PIL``. + This function can be called with ``python3 -m PIL``. + It can also be called with ``python3 -m PIL.report`` or ``python3 -m PIL --report`` + to have "supported_formats" set to ``False``, omitting the list of all supported + image file formats. :param out: The output stream to print to. Defaults to ``sys.stdout`` if ``None``. @@ -231,17 +261,22 @@ def pilinfo(out=None, supported_formats=True): print("-" * 68, file=out) print(f"Pillow {PIL.__version__}", file=out) - py_version = sys.version.splitlines() - print(f"Python {py_version[0].strip()}", file=out) - for py_version in py_version[1:]: + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) + print(f"Python executable is {sys.executable or 'unknown'}", file=out) + if sys.prefix != sys.base_prefix: + print(f"Environment Python files loaded from {sys.prefix}", file=out) + print(f"System Python files loaded from {sys.base_prefix}", file=out) + print("-" * 68, file=out) print( - f"Python modules loaded from {os.path.dirname(Image.__file__)}", + f"Python Pillow modules loaded from {os.path.dirname(Image.__file__)}", file=out, ) print( - f"Binary modules loaded from {os.path.dirname(Image.core.__file__)}", + f"Binary Pillow modules loaded from {os.path.dirname(Image.core.__file__)}", file=out, ) print("-" * 68, file=out) @@ -252,9 +287,6 @@ def pilinfo(out=None, supported_formats=True): ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), - ("transp_webp", "WEBP Transparency"), - ("webp_mux", "WEBPMUX"), - ("webp_anim", "WEBP Animation"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"), @@ -264,9 +296,12 @@ def pilinfo(out=None, supported_formats=True): ("xcb", "XCB (X protocol)"), ]: if check(name): - if name == "jpg" and check_feature("libjpeg_turbo"): - v = "libjpeg-turbo " + version_feature("libjpeg_turbo") - else: + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "libjpeg-turbo " + libjpeg_turbo_version + if v is None: v = version(name) if v is not None: version_static = name in ("pil", "jpg") @@ -274,6 +309,15 @@ def pilinfo(out=None, supported_formats=True): # this check is also in src/_imagingcms.c:setup_module() version_static = tuple(int(x) for x in v.split(".")) < (2, 7) t = "compiled for" if version_static else "loaded" + if name == "zlib": + zlib_ng_version = version_feature("zlib_ng") + if zlib_ng_version is not None: + v += ", compiled for zlib-ng " + zlib_ng_version + elif name == "raqm": + for f in ("fribidi", "harfbuzz"): + v2 = version_feature(f) + if v2 is not None: + v += f", {f} {v2}" print("---", feature, "support ok,", t, v, file=out) else: print("---", feature, "support ok", file=out) diff --git a/dependencies/OSX/PIL/py.typed b/dependencies/OSX/PIL/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dependencies/OSX/PIL/report.py b/dependencies/OSX/PIL/report.py new file mode 100644 index 0000000..d2815e8 --- /dev/null +++ b/dependencies/OSX/PIL/report.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .features import pilinfo + +pilinfo(supported_formats=False) diff --git a/dependencies/WIN/PIL/BdfFontFile.py b/dependencies/WIN/PIL/BdfFontFile.py index 102b72e..bc1416c 100644 --- a/dependencies/WIN/PIL/BdfFontFile.py +++ b/dependencies/WIN/PIL/BdfFontFile.py @@ -20,7 +20,9 @@ """ Parse X Bitmap Distribution Format (BDF) """ +from __future__ import annotations +from typing import BinaryIO from . import FontFile, Image @@ -36,7 +38,17 @@ bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"} -def bdf_char(f): +def bdf_char( + f: BinaryIO, +) -> ( + tuple[ + str, + int, + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]], + Image.Image, + ] + | None +): # skip to STARTCHAR while True: s = f.readline() @@ -56,24 +68,34 @@ def bdf_char(f): props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") # load bitmap - bitmap = [] + bitmap = bytearray() while True: s = f.readline() if not s or s[:7] == b"ENDCHAR": break - bitmap.append(s[:-1]) - bitmap = b"".join(bitmap) + bitmap += s[:-1] - [x, y, l, d] = [int(p) for p in props["BBX"].split()] - [dx, dy] = [int(p) for p in props["DWIDTH"].split()] + # The word BBX + # followed by the width in x (BBw), height in y (BBh), + # and x and y displacement (BBxoff0, BByoff0) + # of the lower left corner from the origin of the character. + width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split()) - bbox = (dx, dy), (l, -d - y, x + l, -d), (0, 0, x, y) + # The word DWIDTH + # followed by the width in x and y of the character in device pixels. + dwx, dwy = (int(p) for p in props["DWIDTH"].split()) + + bbox = ( + (dwx, dwy), + (x_disp, -y_disp - height, width + x_disp, -y_disp), + (0, 0, width, height), + ) try: - im = Image.frombytes("1", (x, y), bitmap, "hex", "1") + im = Image.frombytes("1", (width, height), bitmap, "hex", "1") except ValueError: # deal with zero-width characters - im = Image.new("1", (x, y)) + im = Image.new("1", (width, height)) return id, int(props["ENCODING"]), bbox, im @@ -81,12 +103,13 @@ def bdf_char(f): class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp): + def __init__(self, fp: BinaryIO) -> None: super().__init__() s = fp.readline() if s[:13] != b"STARTFONT 2.1": - raise SyntaxError("not a valid BDF file") + msg = "not a valid BDF file" + raise SyntaxError(msg) props = {} comments = [] diff --git a/dependencies/WIN/PIL/BlpImagePlugin.py b/dependencies/WIN/PIL/BlpImagePlugin.py index 7b78597..c932b3b 100644 --- a/dependencies/WIN/PIL/BlpImagePlugin.py +++ b/dependencies/WIN/PIL/BlpImagePlugin.py @@ -29,27 +29,41 @@ - DXT5 compression is used if alpha_encoding == 7. """ +from __future__ import annotations + +import abc +import os import struct +from enum import IntEnum from io import BytesIO +from typing import IO from . import Image, ImageFile -BLP_FORMAT_JPEG = 0 -BLP_ENCODING_UNCOMPRESSED = 1 -BLP_ENCODING_DXT = 2 -BLP_ENCODING_UNCOMPRESSED_RAW_BGRA = 3 +class Format(IntEnum): + JPEG = 0 + + +class Encoding(IntEnum): + UNCOMPRESSED = 1 + DXT = 2 + UNCOMPRESSED_RAW_BGRA = 3 -BLP_ALPHA_ENCODING_DXT1 = 0 -BLP_ALPHA_ENCODING_DXT3 = 1 -BLP_ALPHA_ENCODING_DXT5 = 7 +class AlphaEncoding(IntEnum): + DXT1 = 0 + DXT3 = 1 + DXT5 = 7 -def unpack_565(i): - return (((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3) +def unpack_565(i: int) -> tuple[int, int, int]: + return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3 -def decode_dxt1(data, alpha=False): + +def decode_dxt1( + data: bytes, alpha: bool = False +) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -57,9 +71,9 @@ def decode_dxt1(data, alpha=False): blocks = len(data) // 8 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): + for block_index in range(blocks): # Decode next 8-byte block. - idx = block * 8 + idx = block_index * 8 color0, color1, bits = struct.unpack_from(" tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -112,8 +126,8 @@ def decode_dxt3(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. bits = struct.unpack_from("<8B", block) @@ -157,7 +171,7 @@ def decode_dxt3(data): return ret -def decode_dxt5(data): +def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4 * width pixels) """ @@ -165,8 +179,8 @@ def decode_dxt5(data): blocks = len(data) // 16 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): - idx = block * 16 + for block_index in range(blocks): + idx = block_index * 16 block = data[idx : idx + 16] # Decode next 16-byte block. a0, a1 = struct.unpack_from(" bool: + return prefix[:4] in (b"BLP1", b"BLP2") + + class BlpImageFile(ImageFile.ImageFile): """ Blizzard Mipmap Format @@ -239,57 +257,65 @@ class BlpImageFile(ImageFile.ImageFile): format = "BLP" format_description = "Blizzard Mipmap Format" - def _open(self): + def _open(self) -> None: self.magic = self.fp.read(4) - self._read_blp_header() + if not _accept(self.magic): + msg = f"Bad BLP magic {repr(self.magic)}" + raise BLPFormatError(msg) + compression = struct.unpack(" tuple[int, int]: try: - self.fd.seek(0) - self.magic = self.fd.read(4) - self._read_blp_header() + self._read_header() self._load() except struct.error as e: - raise OSError("Truncated Blp file") from e - return 0, 0 + msg = "Truncated BLP file" + raise OSError(msg) from e + return -1, 0 + + @abc.abstractmethod + def _load(self) -> None: + pass + + def _read_header(self) -> None: + self._offsets = struct.unpack("<16I", self._safe_read(16 * 4)) + self._lengths = struct.unpack("<16I", self._safe_read(16 * 4)) - def _safe_read(self, length): + def _safe_read(self, length: int) -> bytes: + assert self.fd is not None return ImageFile._safe_read(self.fd, length) - def _read_palette(self): + def _read_palette(self) -> list[tuple[int, int, int, int]]: ret = [] for i in range(256): try: @@ -299,130 +325,177 @@ def _read_palette(self): ret.append((b, g, r, a)) return ret - def _read_blp_header(self): - (self._blp_compression,) = struct.unpack(" bytearray: + data = bytearray() + _data = BytesIO(self._safe_read(self._lengths[0])) + while True: + try: + (offset,) = struct.unpack(" None: + self._compression, self._encoding, alpha = self.args + + if self._compression == Format.JPEG: self._decode_jpeg_stream() - elif self._blp_compression == 1: - if self._blp_encoding in (4, 5): - data = bytearray() + elif self._compression == 1: + if self._encoding in (4, 5): palette = self._read_palette() - _data = BytesIO(self._safe_read(self._blp_lengths[0])) - while True: - try: - (offset,) = struct.unpack(" None: + from .JpegImagePlugin import JpegImageFile (jpeg_header_size,) = struct.unpack(" None: + self._compression, self._encoding, alpha, self._alpha_encoding = self.args + palette = self._read_palette() - data = bytearray() - self.fd.seek(self._blp_offsets[0]) + assert self.fd is not None + self.fd.seek(self._offsets[0]) - if self._blp_compression == 1: + if self._compression == 1: # Uncompressed or DirectX compression - if self._blp_encoding == BLP_ENCODING_UNCOMPRESSED: - _data = BytesIO(self._safe_read(self._blp_lengths[0])) - while True: - try: - (offset,) = struct.unpack(" bytes: + data = b"" + assert self.im is not None + palette = self.im.getpalette("RGBA", "RGBA") + for i in range(len(palette) // 4): + r, g, b, a = palette[i * 4 : (i + 1) * 4] + data += struct.pack("<4B", b, g, r, a) + while len(data) < 256 * 4: + data += b"\x00" * 4 + return data + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + palette_data = self._write_palette() + + offset = 20 + 16 * 4 * 2 + len(palette_data) + data = struct.pack("<16I", offset, *((0,) * 15)) + + assert self.im is not None + w, h = self.im.size + data += struct.pack("<16I", w * h, *((0,) * 15)) + + data += palette_data + + for y in range(h): + for x in range(w): + data += struct.pack(" None: + if im.mode != "P": + msg = "Unsupported BLP image mode" + raise ValueError(msg) + + magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2" + fp.write(magic) + + assert im.palette is not None + fp.write(struct.pack(" bool: return prefix[:2] == b"BM" -def _dib_accept(prefix): - return i32(prefix) in [12, 40, 64, 108, 124] +def _dib_accept(prefix: bytes) -> bool: + return i32(prefix) in [12, 40, 52, 56, 64, 108, 124] # ============================================================================= @@ -69,40 +72,48 @@ class BmpImageFile(ImageFile.ImageFile): for k, v in COMPRESSIONS.items(): vars()[k] = v - def _bitmap(self, header=0, offset=0): + def _bitmap(self, header: int = 0, offset: int = 0) -> None: """Read relevant info about the BMP""" read, seek = self.fp.read, self.fp.seek if header: seek(header) - file_info = {} # read bmp header size @offset 14 (this is part of the header size) - file_info["header_size"] = i32(read(4)) - file_info["direction"] = -1 + file_info: dict[str, bool | int | tuple[int, ...]] = { + "header_size": i32(read(4)), + "direction": -1, + } # -------------------- If requested, read header at a specific position # read the rest of the bmp header, without its size + assert isinstance(file_info["header_size"], int) header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4) - # -------------------------------------------------- IBM OS/2 Bitmap v1 + # ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1 # ----- This format has different offsets because of width/height types + # 12: BITMAPCOREHEADER/OS21XBITMAPHEADER if file_info["header_size"] == 12: file_info["width"] = i16(header_data, 0) file_info["height"] = i16(header_data, 2) file_info["planes"] = i16(header_data, 4) file_info["bits"] = i16(header_data, 6) - file_info["compression"] = self.RAW + file_info["compression"] = self.COMPRESSIONS["RAW"] file_info["palette_padding"] = 3 - # --------------------------------------------- Windows Bitmap v2 to v5 - # v3, OS/2 v2, v4, v5 - elif file_info["header_size"] in (40, 64, 108, 124): + # --------------------------------------------- Windows Bitmap v3 to v5 + # 40: BITMAPINFOHEADER + # 52: BITMAPV2HEADER + # 56: BITMAPV3HEADER + # 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER + # 108: BITMAPV4HEADER + # 124: BITMAPV5HEADER + elif file_info["header_size"] in (40, 52, 56, 64, 108, 124): file_info["y_flip"] = header_data[7] == 0xFF file_info["direction"] = 1 if file_info["y_flip"] else -1 file_info["width"] = i32(header_data, 0) file_info["height"] = ( i32(header_data, 4) if not file_info["y_flip"] - else 2 ** 32 - i32(header_data, 4) + else 2**32 - i32(header_data, 4) ) file_info["planes"] = i16(header_data, 8) file_info["bits"] = i16(header_data, 10) @@ -115,12 +126,16 @@ def _bitmap(self, header=0, offset=0): ) file_info["colors"] = i32(header_data, 28) file_info["palette_padding"] = 4 + assert isinstance(file_info["pixels_per_meter"], tuple) self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"]) - if file_info["compression"] == self.BITFIELDS: - if len(header_data) >= 52: - for idx, mask in enumerate( - ["r_mask", "g_mask", "b_mask", "a_mask"] - ): + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + masks = ["r_mask", "g_mask", "b_mask"] + if len(header_data) >= 48: + if len(header_data) >= 52: + masks.append("a_mask") + else: + file_info["a_mask"] = 0x0 + for idx, mask in enumerate(masks): file_info[mask] = i32(header_data, 36 + idx * 4) else: # 40 byte headers only have the three components in the @@ -132,8 +147,12 @@ def _bitmap(self, header=0, offset=0): # location, but it is listed as a reserved component, # and it is not generally an alpha channel file_info["a_mask"] = 0x0 - for mask in ["r_mask", "g_mask", "b_mask"]: + for mask in masks: file_info[mask] = i32(read(4)) + assert isinstance(file_info["r_mask"], int) + assert isinstance(file_info["g_mask"], int) + assert isinstance(file_info["b_mask"], int) + assert isinstance(file_info["a_mask"], int) file_info["rgb_mask"] = ( file_info["r_mask"], file_info["g_mask"], @@ -146,33 +165,45 @@ def _bitmap(self, header=0, offset=0): file_info["a_mask"], ) else: - raise OSError(f"Unsupported BMP header type ({file_info['header_size']})") + msg = f"Unsupported BMP header type ({file_info['header_size']})" + raise OSError(msg) # ------------------ Special case : header is reported 40, which # ---------------------- is shorter than real size for bpp >= 16 + assert isinstance(file_info["width"], int) + assert isinstance(file_info["height"], int) self._size = file_info["width"], file_info["height"] # ------- If color count was not found in the header, compute from bits + assert isinstance(file_info["bits"], int) file_info["colors"] = ( file_info["colors"] if file_info.get("colors", 0) else (1 << file_info["bits"]) ) + assert isinstance(file_info["colors"], int) + if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8: + offset += 4 * file_info["colors"] # ---------------------- Check bit depth for unusual unsupported values - self.mode, raw_mode = BIT2MODE.get(file_info["bits"], (None, None)) - if self.mode is None: - raise OSError(f"Unsupported BMP pixel depth ({file_info['bits']})") + self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", "")) + if not self.mode: + msg = f"Unsupported BMP pixel depth ({file_info['bits']})" + raise OSError(msg) # ---------------- Process BMP with Bitfields compression (not palette) - if file_info["compression"] == self.BITFIELDS: - SUPPORTED = { + decoder_name = "raw" + if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]: + SUPPORTED: dict[int, list[tuple[int, ...]]] = { 32: [ (0xFF0000, 0xFF00, 0xFF, 0x0), - (0xFF0000, 0xFF00, 0xFF, 0xFF000000), + (0xFF000000, 0xFF0000, 0xFF00, 0x0), + (0xFF000000, 0xFF00, 0xFF, 0x0), + (0xFF000000, 0xFF0000, 0xFF00, 0xFF), (0xFF, 0xFF00, 0xFF0000, 0xFF000000), + (0xFF0000, 0xFF00, 0xFF, 0xFF000000), + (0xFF000000, 0xFF00, 0xFF, 0xFF0000), (0x0, 0x0, 0x0, 0x0), - (0xFF000000, 0xFF0000, 0xFF00, 0x0), ], 24: [(0xFF0000, 0xFF00, 0xFF)], 16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)], @@ -180,8 +211,11 @@ def _bitmap(self, header=0, offset=0): MASK_MODES = { (32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX", (32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR", + (32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR", + (32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR", (32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA", (32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA", + (32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR", (32, (0x0, 0x0, 0x0, 0x0)): "BGRA", (24, (0xFF0000, 0xFF00, 0xFF)): "BGR", (16, (0xF800, 0x7E0, 0x1F)): "BGR;16", @@ -192,92 +226,179 @@ def _bitmap(self, header=0, offset=0): file_info["bits"] == 32 and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgba_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])] - self.mode = "RGBA" if "A" in raw_mode else self.mode + self._mode = "RGBA" if "A" in raw_mode else self.mode elif ( file_info["bits"] in (24, 16) and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]] ): + assert isinstance(file_info["rgb_mask"], tuple) raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])] else: - raise OSError("Unsupported BMP bitfields layout") + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) else: - raise OSError("Unsupported BMP bitfields layout") - elif file_info["compression"] == self.RAW: + msg = "Unsupported BMP bitfields layout" + raise OSError(msg) + elif file_info["compression"] == self.COMPRESSIONS["RAW"]: if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset - raw_mode, self.mode = "BGRA", "RGBA" + raw_mode, self._mode = "BGRA", "RGBA" + elif file_info["compression"] in ( + self.COMPRESSIONS["RLE8"], + self.COMPRESSIONS["RLE4"], + ): + decoder_name = "bmp_rle" else: - raise OSError(f"Unsupported BMP compression ({file_info['compression']})") + msg = f"Unsupported BMP compression ({file_info['compression']})" + raise OSError(msg) # --------------- Once the header is processed, process the palette/LUT if self.mode == "P": # Paletted for 1, 4 and 8 bit images - # ---------------------------------------------------- 1-bit images if not (0 < file_info["colors"] <= 65536): - raise OSError(f"Unsupported BMP Palette size ({file_info['colors']})") + msg = f"Unsupported BMP Palette size ({file_info['colors']})" + raise OSError(msg) else: + assert isinstance(file_info["palette_padding"], int) padding = file_info["palette_padding"] palette = read(padding * file_info["colors"]) - greyscale = True + grayscale = True indices = ( (0, 255) if file_info["colors"] == 2 else list(range(file_info["colors"])) ) - # ----------------- Check if greyscale and ignore palette if so + # ----------------- Check if grayscale and ignore palette if so for ind, val in enumerate(indices): rgb = palette[ind * padding : ind * padding + 3] if rgb != o8(val) * 3: - greyscale = False + grayscale = False - # ------- If all colors are grey, white or black, ditch palette - if greyscale: - self.mode = "1" if file_info["colors"] == 2 else "L" + # ------- If all colors are gray, white or black, ditch palette + if grayscale: + self._mode = "1" if file_info["colors"] == 2 else "L" raw_mode = self.mode else: - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw( "BGRX" if padding == 4 else "BGR", palette ) # ---------------------------- Finally set the tile data for the plugin self.info["compression"] = file_info["compression"] + args: list[Any] = [raw_mode] + if decoder_name == "bmp_rle": + args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"]) + else: + assert isinstance(file_info["width"], int) + args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3)) + args.append(file_info["direction"]) self.tile = [ - ( - "raw", + ImageFile._Tile( + decoder_name, (0, 0, file_info["width"], file_info["height"]), offset or self.fp.tell(), - ( - raw_mode, - ((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3), - file_info["direction"], - ), + tuple(args), ) ] - def _open(self): + def _open(self) -> None: """Open file, check magic number and read header""" # read 14 bytes: magic number, filesize, reserved, header final offset head_data = self.fp.read(14) # choke if the file does not have the required magic bytes if not _accept(head_data): - raise SyntaxError("Not a BMP file") + msg = "Not a BMP file" + raise SyntaxError(msg) # read the start position of the BMP image data (u32) offset = i32(head_data, 10) # load bitmap information (offset=raster info) self._bitmap(offset=offset) +class BmpRleDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + rle4 = self.args[1] + data = bytearray() + x = 0 + dest_length = self.state.xsize * self.state.ysize + while len(data) < dest_length: + pixels = self.fd.read(1) + byte = self.fd.read(1) + if not pixels or not byte: + break + num_pixels = pixels[0] + if num_pixels: + # encoded mode + if x + num_pixels > self.state.xsize: + # Too much data for row + num_pixels = max(0, self.state.xsize - x) + if rle4: + first_pixel = o8(byte[0] >> 4) + second_pixel = o8(byte[0] & 0x0F) + for index in range(num_pixels): + if index % 2 == 0: + data += first_pixel + else: + data += second_pixel + else: + data += byte * num_pixels + x += num_pixels + else: + if byte[0] == 0: + # end of line + while len(data) % self.state.xsize != 0: + data += b"\x00" + x = 0 + elif byte[0] == 1: + # end of bitmap + break + elif byte[0] == 2: + # delta + bytes_read = self.fd.read(2) + if len(bytes_read) < 2: + break + right, up = self.fd.read(2) + data += b"\x00" * (right + up * self.state.xsize) + x = len(data) % self.state.xsize + else: + # absolute mode + if rle4: + # 2 pixels per byte + byte_count = byte[0] // 2 + bytes_read = self.fd.read(byte_count) + for byte_read in bytes_read: + data += o8(byte_read >> 4) + data += o8(byte_read & 0x0F) + else: + byte_count = byte[0] + bytes_read = self.fd.read(byte_count) + data += bytes_read + if len(bytes_read) < byte_count: + break + x += byte[0] + + # align to 16-bit word boundary + if self.fd.tell() % 2 != 0: + self.fd.seek(1, os.SEEK_CUR) + rawmode = "L" if self.mode == "L" else "P" + self.set_as_raw(bytes(data), rawmode, (0, self.args[-1])) + return -1, 0 + + # ============================================================================= # Image plugin for the DIB format (BMP alias) # ============================================================================= class DibImageFile(BmpImageFile): - format = "DIB" format_description = "Windows Bitmap" - def _open(self): + def _open(self) -> None: self._bitmap() @@ -295,33 +416,47 @@ def _open(self): } -def _dib_save(im, fp, filename): +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, False) -def _save(im, fp, filename, bitmap_header=True): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True +) -> None: try: rawmode, bits, colors = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as BMP") from e + msg = f"cannot write mode {im.mode} as BMP" + raise OSError(msg) from e info = im.encoderinfo dpi = info.get("dpi", (96, 96)) # 1 meter == 39.3701 inches - ppm = tuple(map(lambda x: int(x * 39.3701 + 0.5), dpi)) + ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi) stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3) header = 40 # or 64 for OS/2 version 2 image = stride * im.size[1] + if im.mode == "1": + palette = b"".join(o8(i) * 4 for i in (0, 255)) + elif im.mode == "L": + palette = b"".join(o8(i) * 4 for i in range(256)) + elif im.mode == "P": + palette = im.im.getpalette("RGB", "BGRX") + colors = len(palette) // 4 + else: + palette = None + # bitmap header if bitmap_header: offset = 14 + header + colors * 4 file_size = offset + image - if file_size > 2 ** 32 - 1: - raise ValueError("File size is too large for the BMP format") + if file_size > 2**32 - 1: + msg = "File size is too large for the BMP format" + raise ValueError(msg) fp.write( b"BM" # file type (magic) + o32(file_size) # file size @@ -346,16 +481,12 @@ def _save(im, fp, filename, bitmap_header=True): fp.write(b"\0" * (header - 40)) # padding (for OS/2 format) - if im.mode == "1": - for i in (0, 255): - fp.write(o8(i) * 4) - elif im.mode == "L": - for i in range(256): - fp.write(o8(i) * 4) - elif im.mode == "P": - fp.write(im.im.getpalette("RGB", "BGRX")) + if palette: + fp.write(palette) - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))] + ) # @@ -370,6 +501,8 @@ def _save(im, fp, filename, bitmap_header=True): Image.register_mime(BmpImageFile.format, "image/bmp") +Image.register_decoder("bmp_rle", BmpRleDecoder) + Image.register_open(DibImageFile.format, DibImageFile, _dib_accept) Image.register_save(DibImageFile.format, _dib_save) diff --git a/dependencies/WIN/PIL/BufrStubImagePlugin.py b/dependencies/WIN/PIL/BufrStubImagePlugin.py index 48f21e1..0ee2f65 100644 --- a/dependencies/WIN/PIL/BufrStubImagePlugin.py +++ b/dependencies/WIN/PIL/BufrStubImagePlugin.py @@ -8,13 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific BUFR image handler. @@ -28,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"BUFR" or prefix[:4] == b"ZCZC" class BufrStubImageFile(ImageFile.StubImageFile): - format = "BUFR" format_description = "BUFR" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(4)): - raise SyntaxError("Not a BUFR file") + msg = "Not a BUFR file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("BUFR save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "BUFR save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/WIN/PIL/ContainerIO.py b/dependencies/WIN/PIL/ContainerIO.py index 45e80b3..ec9e66c 100644 --- a/dependencies/WIN/PIL/ContainerIO.py +++ b/dependencies/WIN/PIL/ContainerIO.py @@ -13,18 +13,20 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import io +from collections.abc import Iterable +from typing import IO, AnyStr, NoReturn -class ContainerIO: +class ContainerIO(IO[AnyStr]): """ A file object that provides read access to a part of an existing file (for example a TAR file). """ - def __init__(self, file, offset, length): + def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None: """ Create file object. @@ -32,7 +34,7 @@ def __init__(self, file, offset, length): :param offset: Start of region, in bytes. :param length: Size of region, in bytes. """ - self.fh = file + self.fh: IO[AnyStr] = file self.pos = 0 self.offset = offset self.length = length @@ -41,10 +43,13 @@ def __init__(self, file, offset, length): ## # Always false. - def isatty(self): + def isatty(self) -> bool: return False - def seek(self, offset, mode=io.SEEK_SET): + def seekable(self) -> bool: + return True + + def seek(self, offset: int, mode: int = io.SEEK_SET) -> int: """ Move file pointer. @@ -52,6 +57,7 @@ def seek(self, offset, mode=io.SEEK_SET): :param mode: Starting position. Use 0 for beginning of region, 1 for current offset, and 2 for end of region. You cannot move the pointer outside the defined region. + :returns: Offset from start of region, in bytes. """ if mode == 1: self.pos = self.pos + offset @@ -62,8 +68,9 @@ def seek(self, offset, mode=io.SEEK_SET): # clamp self.pos = max(0, min(self.pos, self.length)) self.fh.seek(self.offset + self.pos) + return self.pos - def tell(self): + def tell(self) -> int: """ Get current file pointer. @@ -71,44 +78,51 @@ def tell(self): """ return self.pos - def read(self, n=0): + def readable(self) -> bool: + return True + + def read(self, n: int = -1) -> AnyStr: """ Read data. - :param n: Number of bytes to read. If omitted or zero, + :param n: Number of bytes to read. If omitted, zero or negative, read until end of region. :returns: An 8-bit string. """ - if n: + if n > 0: n = min(n, self.length - self.pos) else: n = self.length - self.pos - if not n: # EOF - return b"" if "b" in self.fh.mode else "" + if n <= 0: # EOF + return b"" if "b" in self.fh.mode else "" # type: ignore[return-value] self.pos = self.pos + n return self.fh.read(n) - def readline(self): + def readline(self, n: int = -1) -> AnyStr: """ Read a line of text. + :param n: Number of bytes to read. If omitted, zero or negative, + read until end of line. :returns: An 8-bit string. """ - s = b"" if "b" in self.fh.mode else "" + s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment] newline_character = b"\n" if "b" in self.fh.mode else "\n" while True: c = self.read(1) if not c: break s = s + c - if c == newline_character: + if c == newline_character or len(s) == n: break return s - def readlines(self): + def readlines(self, n: int | None = -1) -> list[AnyStr]: """ Read multiple lines of text. + :param n: Number of lines to read. If omitted, zero, negative or None, + read until end of region. :returns: A list of 8-bit strings. """ lines = [] @@ -117,4 +131,43 @@ def readlines(self): if not s: break lines.append(s) + if len(lines) == n: + break return lines + + def writable(self) -> bool: + return False + + def write(self, b: AnyStr) -> NoReturn: + raise NotImplementedError() + + def writelines(self, lines: Iterable[AnyStr]) -> NoReturn: + raise NotImplementedError() + + def truncate(self, size: int | None = None) -> int: + raise NotImplementedError() + + def __enter__(self) -> ContainerIO[AnyStr]: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def __iter__(self) -> ContainerIO[AnyStr]: + return self + + def __next__(self) -> AnyStr: + line = self.readline() + if not line: + msg = "end of region" + raise StopIteration(msg) + return line + + def fileno(self) -> int: + return self.fh.fileno() + + def flush(self) -> None: + self.fh.flush() + + def close(self) -> None: + self.fh.close() diff --git a/dependencies/WIN/PIL/CurImagePlugin.py b/dependencies/WIN/PIL/CurImagePlugin.py index 42af5ca..c4be0ce 100644 --- a/dependencies/WIN/PIL/CurImagePlugin.py +++ b/dependencies/WIN/PIL/CurImagePlugin.py @@ -15,7 +15,9 @@ # # See the README file for information on usage and redistribution. # -from . import BmpImagePlugin, Image +from __future__ import annotations + +from . import BmpImagePlugin, Image, ImageFile from ._binary import i16le as i16 from ._binary import i32le as i32 @@ -23,7 +25,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"\0\0\2\0" @@ -32,18 +34,17 @@ def _accept(prefix): class CurImageFile(BmpImagePlugin.BmpImageFile): - format = "CUR" format_description = "Windows Cursor" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() # check magic s = self.fp.read(6) if not _accept(s): - raise SyntaxError("not a CUR file") + msg = "not a CUR file" + raise SyntaxError(msg) # pick the largest cursor in the file m = b"" @@ -54,7 +55,8 @@ def _open(self): elif s[0] > m[0] and s[1] > m[1]: m = s if not m: - raise TypeError("No cursors were found") + msg = "No cursors were found" + raise TypeError(msg) # load as bitmap self._bitmap(i32(m, 12) + offset) @@ -62,9 +64,7 @@ def _open(self): # patch up the bitmap height self._size = self.size[0], self.size[1] // 2 d, e, o, a = self.tile[0] - self.tile[0] = d, (0, 0) + self.size, o, a - - return + self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a) # diff --git a/dependencies/WIN/PIL/DcxImagePlugin.py b/dependencies/WIN/PIL/DcxImagePlugin.py index de21db8..f67f27d 100644 --- a/dependencies/WIN/PIL/DcxImagePlugin.py +++ b/dependencies/WIN/PIL/DcxImagePlugin.py @@ -20,6 +20,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image from ._binary import i32le as i32 @@ -28,7 +29,7 @@ MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 4 and i32(prefix) == MAGIC @@ -37,17 +38,16 @@ def _accept(prefix): class DcxImageFile(PcxImageFile): - format = "DCX" format_description = "Intel DCX" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # Header s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a DCX file") + msg = "not a DCX file" + raise SyntaxError(msg) # Component directory self._offset = [] @@ -57,32 +57,23 @@ def _open(self): break self._offset.append(offset) - self.__fp = self.fp - self.frame = None + self._fp = self.fp + self.frame = -1 self.n_frames = len(self._offset) self.is_animated = self.n_frames > 1 self.seek(0) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return self.frame = frame - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self._offset[frame]) PcxImageFile._open(self) - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - Image.register_open(DcxImageFile.format, DcxImageFile, _accept) diff --git a/dependencies/WIN/PIL/DdsImagePlugin.py b/dependencies/WIN/PIL/DdsImagePlugin.py index 260924f..9349e28 100644 --- a/dependencies/WIN/PIL/DdsImagePlugin.py +++ b/dependencies/WIN/PIL/DdsImagePlugin.py @@ -3,245 +3,571 @@ Jerome Leclanche Documentation: - https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt +https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: - https://creativecommons.org/publicdomain/zero/1.0/ +https://creativecommons.org/publicdomain/zero/1.0/ """ +from __future__ import annotations + +import io import struct -from io import BytesIO +import sys +from enum import IntEnum, IntFlag +from typing import IO -from . import Image, ImageFile +from . import Image, ImageFile, ImagePalette +from ._binary import i32le as i32 +from ._binary import o8 from ._binary import o32le as o32 # Magic ("DDS ") DDS_MAGIC = 0x20534444 -# DDS flags -DDSD_CAPS = 0x1 -DDSD_HEIGHT = 0x2 -DDSD_WIDTH = 0x4 -DDSD_PITCH = 0x8 -DDSD_PIXELFORMAT = 0x1000 -DDSD_MIPMAPCOUNT = 0x20000 -DDSD_LINEARSIZE = 0x80000 -DDSD_DEPTH = 0x800000 - -# DDS caps -DDSCAPS_COMPLEX = 0x8 -DDSCAPS_TEXTURE = 0x1000 -DDSCAPS_MIPMAP = 0x400000 - -DDSCAPS2_CUBEMAP = 0x200 -DDSCAPS2_CUBEMAP_POSITIVEX = 0x400 -DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800 -DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000 -DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000 -DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000 -DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000 -DDSCAPS2_VOLUME = 0x200000 - -# Pixel Format -DDPF_ALPHAPIXELS = 0x1 -DDPF_ALPHA = 0x2 -DDPF_FOURCC = 0x4 -DDPF_PALETTEINDEXED8 = 0x20 -DDPF_RGB = 0x40 -DDPF_LUMINANCE = 0x20000 - -# dds.h - -DDS_FOURCC = DDPF_FOURCC -DDS_RGB = DDPF_RGB -DDS_RGBA = DDPF_RGB | DDPF_ALPHAPIXELS -DDS_LUMINANCE = DDPF_LUMINANCE -DDS_LUMINANCEA = DDPF_LUMINANCE | DDPF_ALPHAPIXELS -DDS_ALPHA = DDPF_ALPHA -DDS_PAL8 = DDPF_PALETTEINDEXED8 - -DDS_HEADER_FLAGS_TEXTURE = DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PIXELFORMAT -DDS_HEADER_FLAGS_MIPMAP = DDSD_MIPMAPCOUNT -DDS_HEADER_FLAGS_VOLUME = DDSD_DEPTH -DDS_HEADER_FLAGS_PITCH = DDSD_PITCH -DDS_HEADER_FLAGS_LINEARSIZE = DDSD_LINEARSIZE - -DDS_HEIGHT = DDSD_HEIGHT -DDS_WIDTH = DDSD_WIDTH +# DDS flags +class DDSD(IntFlag): + CAPS = 0x1 + HEIGHT = 0x2 + WIDTH = 0x4 + PITCH = 0x8 + PIXELFORMAT = 0x1000 + MIPMAPCOUNT = 0x20000 + LINEARSIZE = 0x80000 + DEPTH = 0x800000 -DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS_TEXTURE -DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS_COMPLEX | DDSCAPS_MIPMAP -DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS_COMPLEX -DDS_CUBEMAP_POSITIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEX -DDS_CUBEMAP_NEGATIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEX -DDS_CUBEMAP_POSITIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEY -DDS_CUBEMAP_NEGATIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEY -DDS_CUBEMAP_POSITIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEZ -DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEZ +# DDS caps +class DDSCAPS(IntFlag): + COMPLEX = 0x8 + TEXTURE = 0x1000 + MIPMAP = 0x400000 -# DXT1 -DXT1_FOURCC = 0x31545844 +class DDSCAPS2(IntFlag): + CUBEMAP = 0x200 + CUBEMAP_POSITIVEX = 0x400 + CUBEMAP_NEGATIVEX = 0x800 + CUBEMAP_POSITIVEY = 0x1000 + CUBEMAP_NEGATIVEY = 0x2000 + CUBEMAP_POSITIVEZ = 0x4000 + CUBEMAP_NEGATIVEZ = 0x8000 + VOLUME = 0x200000 -# DXT3 -DXT3_FOURCC = 0x33545844 -# DXT5 -DXT5_FOURCC = 0x35545844 +# Pixel Format +class DDPF(IntFlag): + ALPHAPIXELS = 0x1 + ALPHA = 0x2 + FOURCC = 0x4 + PALETTEINDEXED8 = 0x20 + RGB = 0x40 + LUMINANCE = 0x20000 # dxgiformat.h - -DXGI_FORMAT_R8G8B8A8_TYPELESS = 27 -DXGI_FORMAT_R8G8B8A8_UNORM = 28 -DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = 29 -DXGI_FORMAT_BC5_TYPELESS = 82 -DXGI_FORMAT_BC5_UNORM = 83 -DXGI_FORMAT_BC5_SNORM = 84 -DXGI_FORMAT_BC7_TYPELESS = 97 -DXGI_FORMAT_BC7_UNORM = 98 -DXGI_FORMAT_BC7_UNORM_SRGB = 99 +class DXGI_FORMAT(IntEnum): + UNKNOWN = 0 + R32G32B32A32_TYPELESS = 1 + R32G32B32A32_FLOAT = 2 + R32G32B32A32_UINT = 3 + R32G32B32A32_SINT = 4 + R32G32B32_TYPELESS = 5 + R32G32B32_FLOAT = 6 + R32G32B32_UINT = 7 + R32G32B32_SINT = 8 + R16G16B16A16_TYPELESS = 9 + R16G16B16A16_FLOAT = 10 + R16G16B16A16_UNORM = 11 + R16G16B16A16_UINT = 12 + R16G16B16A16_SNORM = 13 + R16G16B16A16_SINT = 14 + R32G32_TYPELESS = 15 + R32G32_FLOAT = 16 + R32G32_UINT = 17 + R32G32_SINT = 18 + R32G8X24_TYPELESS = 19 + D32_FLOAT_S8X24_UINT = 20 + R32_FLOAT_X8X24_TYPELESS = 21 + X32_TYPELESS_G8X24_UINT = 22 + R10G10B10A2_TYPELESS = 23 + R10G10B10A2_UNORM = 24 + R10G10B10A2_UINT = 25 + R11G11B10_FLOAT = 26 + R8G8B8A8_TYPELESS = 27 + R8G8B8A8_UNORM = 28 + R8G8B8A8_UNORM_SRGB = 29 + R8G8B8A8_UINT = 30 + R8G8B8A8_SNORM = 31 + R8G8B8A8_SINT = 32 + R16G16_TYPELESS = 33 + R16G16_FLOAT = 34 + R16G16_UNORM = 35 + R16G16_UINT = 36 + R16G16_SNORM = 37 + R16G16_SINT = 38 + R32_TYPELESS = 39 + D32_FLOAT = 40 + R32_FLOAT = 41 + R32_UINT = 42 + R32_SINT = 43 + R24G8_TYPELESS = 44 + D24_UNORM_S8_UINT = 45 + R24_UNORM_X8_TYPELESS = 46 + X24_TYPELESS_G8_UINT = 47 + R8G8_TYPELESS = 48 + R8G8_UNORM = 49 + R8G8_UINT = 50 + R8G8_SNORM = 51 + R8G8_SINT = 52 + R16_TYPELESS = 53 + R16_FLOAT = 54 + D16_UNORM = 55 + R16_UNORM = 56 + R16_UINT = 57 + R16_SNORM = 58 + R16_SINT = 59 + R8_TYPELESS = 60 + R8_UNORM = 61 + R8_UINT = 62 + R8_SNORM = 63 + R8_SINT = 64 + A8_UNORM = 65 + R1_UNORM = 66 + R9G9B9E5_SHAREDEXP = 67 + R8G8_B8G8_UNORM = 68 + G8R8_G8B8_UNORM = 69 + BC1_TYPELESS = 70 + BC1_UNORM = 71 + BC1_UNORM_SRGB = 72 + BC2_TYPELESS = 73 + BC2_UNORM = 74 + BC2_UNORM_SRGB = 75 + BC3_TYPELESS = 76 + BC3_UNORM = 77 + BC3_UNORM_SRGB = 78 + BC4_TYPELESS = 79 + BC4_UNORM = 80 + BC4_SNORM = 81 + BC5_TYPELESS = 82 + BC5_UNORM = 83 + BC5_SNORM = 84 + B5G6R5_UNORM = 85 + B5G5R5A1_UNORM = 86 + B8G8R8A8_UNORM = 87 + B8G8R8X8_UNORM = 88 + R10G10B10_XR_BIAS_A2_UNORM = 89 + B8G8R8A8_TYPELESS = 90 + B8G8R8A8_UNORM_SRGB = 91 + B8G8R8X8_TYPELESS = 92 + B8G8R8X8_UNORM_SRGB = 93 + BC6H_TYPELESS = 94 + BC6H_UF16 = 95 + BC6H_SF16 = 96 + BC7_TYPELESS = 97 + BC7_UNORM = 98 + BC7_UNORM_SRGB = 99 + AYUV = 100 + Y410 = 101 + Y416 = 102 + NV12 = 103 + P010 = 104 + P016 = 105 + OPAQUE_420 = 106 + YUY2 = 107 + Y210 = 108 + Y216 = 109 + NV11 = 110 + AI44 = 111 + IA44 = 112 + P8 = 113 + A8P8 = 114 + B4G4R4A4_UNORM = 115 + P208 = 130 + V208 = 131 + V408 = 132 + SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189 + SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190 + + +class D3DFMT(IntEnum): + UNKNOWN = 0 + R8G8B8 = 20 + A8R8G8B8 = 21 + X8R8G8B8 = 22 + R5G6B5 = 23 + X1R5G5B5 = 24 + A1R5G5B5 = 25 + A4R4G4B4 = 26 + R3G3B2 = 27 + A8 = 28 + A8R3G3B2 = 29 + X4R4G4B4 = 30 + A2B10G10R10 = 31 + A8B8G8R8 = 32 + X8B8G8R8 = 33 + G16R16 = 34 + A2R10G10B10 = 35 + A16B16G16R16 = 36 + A8P8 = 40 + P8 = 41 + L8 = 50 + A8L8 = 51 + A4L4 = 52 + V8U8 = 60 + L6V5U5 = 61 + X8L8V8U8 = 62 + Q8W8V8U8 = 63 + V16U16 = 64 + A2W10V10U10 = 67 + D16_LOCKABLE = 70 + D32 = 71 + D15S1 = 73 + D24S8 = 75 + D24X8 = 77 + D24X4S4 = 79 + D16 = 80 + D32F_LOCKABLE = 82 + D24FS8 = 83 + D32_LOCKABLE = 84 + S8_LOCKABLE = 85 + L16 = 81 + VERTEXDATA = 100 + INDEX16 = 101 + INDEX32 = 102 + Q16W16V16U16 = 110 + R16F = 111 + G16R16F = 112 + A16B16G16R16F = 113 + R32F = 114 + G32R32F = 115 + A32B32G32R32F = 116 + CxV8U8 = 117 + A1 = 118 + A2B10G10R10_XR_BIAS = 119 + BINARYBUFFER = 199 + + UYVY = i32(b"UYVY") + R8G8_B8G8 = i32(b"RGBG") + YUY2 = i32(b"YUY2") + G8R8_G8B8 = i32(b"GRGB") + DXT1 = i32(b"DXT1") + DXT2 = i32(b"DXT2") + DXT3 = i32(b"DXT3") + DXT4 = i32(b"DXT4") + DXT5 = i32(b"DXT5") + DX10 = i32(b"DX10") + BC4S = i32(b"BC4S") + BC4U = i32(b"BC4U") + BC5S = i32(b"BC5S") + BC5U = i32(b"BC5U") + ATI1 = i32(b"ATI1") + ATI2 = i32(b"ATI2") + MULTI2_ARGB8 = i32(b"MET1") + + +# Backward compatibility layer +module = sys.modules[__name__] +for item in DDSD: + assert item.name is not None + setattr(module, f"DDSD_{item.name}", item.value) +for item1 in DDSCAPS: + assert item1.name is not None + setattr(module, f"DDSCAPS_{item1.name}", item1.value) +for item2 in DDSCAPS2: + assert item2.name is not None + setattr(module, f"DDSCAPS2_{item2.name}", item2.value) +for item3 in DDPF: + assert item3.name is not None + setattr(module, f"DDPF_{item3.name}", item3.value) + +DDS_FOURCC = DDPF.FOURCC +DDS_RGB = DDPF.RGB +DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS +DDS_LUMINANCE = DDPF.LUMINANCE +DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS +DDS_ALPHA = DDPF.ALPHA +DDS_PAL8 = DDPF.PALETTEINDEXED8 + +DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT +DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT +DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH +DDS_HEADER_FLAGS_PITCH = DDSD.PITCH +DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE + +DDS_HEIGHT = DDSD.HEIGHT +DDS_WIDTH = DDSD.WIDTH + +DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE +DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP +DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX + +DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX +DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX +DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY +DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY +DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ +DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ + +DXT1_FOURCC = D3DFMT.DXT1 +DXT3_FOURCC = D3DFMT.DXT3 +DXT5_FOURCC = D3DFMT.DXT5 + +DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS +DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM +DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB +DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS +DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM +DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM +DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16 +DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16 +DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS +DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM +DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB class DdsImageFile(ImageFile.ImageFile): format = "DDS" format_description = "DirectDraw Surface" - def _open(self): - magic, header_size = struct.unpack(" None: + if not _accept(self.fp.read(4)): + msg = "not a DDS file" + raise SyntaxError(msg) + (header_size,) = struct.unpack(" None: pass -def _save(im, fp, filename): - if im.mode not in ("RGB", "RGBA"): - raise OSError(f"cannot write mode {im.mode} as DDS") +class DdsRgbDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + bitcount, masks = self.args + + # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 + # Calculate how many zeros each mask is padded with + mask_offsets = [] + # And the maximum value of each channel without the padding + mask_totals = [] + for mask in masks: + offset = 0 + if mask != 0: + while mask >> (offset + 1) << (offset + 1) == mask: + offset += 1 + mask_offsets.append(offset) + mask_totals.append(mask >> offset) + + data = bytearray() + bytecount = bitcount // 8 + dest_length = self.state.xsize * self.state.ysize * len(masks) + while len(data) < dest_length: + value = int.from_bytes(self.fd.read(bytecount), "little") + for i, mask in enumerate(masks): + masked_value = value & mask + # Remove the zero padding, and scale it to 8 bits + data += o8( + int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + ) + self.set_as_raw(data) + return -1, 0 + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in ("RGB", "RGBA", "L", "LA"): + msg = f"cannot write mode {im.mode} as DDS" + raise OSError(msg) + + alpha = im.mode[-1] == "A" + if im.mode[0] == "L": + pixel_flags = DDPF.LUMINANCE + rawmode = im.mode + if alpha: + rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] + else: + rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] + else: + pixel_flags = DDPF.RGB + rawmode = im.mode[::-1] + rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] + + if alpha: + r, g, b, a = im.split() + im = Image.merge("RGBA", (a, r, g, b)) + if alpha: + pixel_flags |= DDPF.ALPHAPIXELS + rgba_mask.append(0xFF000000 if alpha else 0) + + flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT + bitcount = len(im.getbands()) * 8 + pitch = (im.width * bitcount + 7) // 8 fp.write( o32(DDS_MAGIC) - + o32(124) # header size - + o32( - DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH | DDSD_PITCH | DDSD_PIXELFORMAT - ) # flags - + o32(im.height) - + o32(im.width) - + o32((im.width * (32 if im.mode == "RGBA" else 24) + 7) // 8) # pitch - + o32(0) # depth - + o32(0) # mipmaps - + o32(0) * 11 # reserved - + o32(32) # pfsize - + o32(DDS_RGBA if im.mode == "RGBA" else DDPF_RGB) # pfflags - + o32(0) # fourcc - + o32(32 if im.mode == "RGBA" else 24) # bitcount - + o32(0xFF0000) # rbitmask - + o32(0xFF00) # gbitmask - + o32(0xFF) # bbitmask - + o32(0xFF000000 if im.mode == "RGBA" else 0) # abitmask - + o32(DDSCAPS_TEXTURE) # dwCaps - + o32(0) # dwCaps2 - + o32(0) # dwCaps3 - + o32(0) # dwCaps4 - + o32(0) # dwReserved2 + + struct.pack( + "<7I", + 124, # header size + flags, # flags + im.height, + im.width, + pitch, + 0, # depth + 0, # mipmaps + ) + + struct.pack("11I", *((0,) * 11)) # reserved + # pfsize, pfflags, fourcc, bitcount + + struct.pack("<4I", 32, pixel_flags, 0, bitcount) + + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) ) - if im.mode == "RGBA": - r, g, b, a = im.split() - im = Image.merge("RGBA", (a, r, g, b)) - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (im.mode[::-1], 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"DDS " Image.register_open(DdsImageFile.format, DdsImageFile, _accept) +Image.register_decoder("dds_rgb", DdsRgbDecoder) Image.register_save(DdsImageFile.format, _save) Image.register_extension(DdsImageFile.format, ".dds") diff --git a/dependencies/WIN/PIL/EpsImagePlugin.py b/dependencies/WIN/PIL/EpsImagePlugin.py index 5d99202..36ba15e 100644 --- a/dependencies/WIN/PIL/EpsImagePlugin.py +++ b/dependencies/WIN/PIL/EpsImagePlugin.py @@ -19,6 +19,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import os @@ -26,58 +27,70 @@ import subprocess import sys import tempfile +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 -# # -------------------------------------------------------------------- + split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$") field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$") +gs_binary: str | bool | None = None gs_windows_binary = None -if sys.platform.startswith("win"): - import shutil - - for binary in ("gswin32c", "gswin64c", "gs"): - if shutil.which(binary) is not None: - gs_windows_binary = binary - break - else: - gs_windows_binary = False - -def has_ghostscript(): - if gs_windows_binary: - return True - if not sys.platform.startswith("win"): - try: - subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL) - return True - except OSError: - # No Ghostscript - pass - return False +def has_ghostscript() -> bool: + global gs_binary, gs_windows_binary + if gs_binary is None: + if sys.platform.startswith("win"): + if gs_windows_binary is None: + import shutil -def Ghostscript(tile, size, fp, scale=1, transparency=False): + for binary in ("gswin32c", "gswin64c", "gs"): + if shutil.which(binary) is not None: + gs_windows_binary = binary + break + else: + gs_windows_binary = False + gs_binary = gs_windows_binary + else: + try: + subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL) + gs_binary = "gs" + except OSError: + gs_binary = False + return gs_binary is not False + + +def Ghostscript( + tile: list[ImageFile._Tile], + size: tuple[int, int], + fp: IO[bytes], + scale: int = 1, + transparency: bool = False, +) -> Image.core.ImagingCore: """Render an image using Ghostscript""" + global gs_binary + if not has_ghostscript(): + msg = "Unable to locate Ghostscript on paths" + raise OSError(msg) + assert isinstance(gs_binary, str) # Unpack decoder tile - decoder, tile, offset, data = tile[0] - length, bbox = data + args = tile[0].args + assert isinstance(args, tuple) + length, bbox = args # Hack to support hi-res rendering scale = int(scale) or 1 - # orig_size = size - # orig_bbox = bbox - size = (size[0] * scale, size[1] * scale) + width = size[0] * scale + height = size[1] * scale # resolution is dependent on bbox and size - res = ( - 72.0 * size[0] / (bbox[2] - bbox[0]), - 72.0 * size[1] / (bbox[3] - bbox[1]), - ) + res_x = 72.0 * width / (bbox[2] - bbox[0]) + res_y = 72.0 * height / (bbox[3] - bbox[1]) out_fd, outfile = tempfile.mkstemp() os.close(out_fd) @@ -108,14 +121,20 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): lengthfile -= len(s) f.write(s) - device = "pngalpha" if transparency else "ppmraw" + if transparency: + # "RGBA" + device = "pngalpha" + else: + # "pnmraw" automatically chooses between + # PBM ("1"), PGM ("L"), and PPM ("RGB"). + device = "pnmraw" # Build Ghostscript command command = [ - "gs", + gs_binary, "-q", # quiet mode - "-g%dx%d" % size, # set output geometry (pixels) - "-r%fx%f" % res, # set input DPI (dots per inch) + f"-g{width:d}x{height:d}", # set output geometry (pixels) + f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch) "-dBATCH", # exit after processing "-dNOPAUSE", # don't pause between pages "-dSAFER", # safe mode @@ -131,11 +150,6 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): "showpage", ] - if gs_windows_binary is not None: - if not gs_windows_binary: - raise OSError("Unable to locate Ghostscript on paths") - command[0] = gs_windows_binary - # push data through Ghostscript try: startupinfo = None @@ -143,8 +157,9 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW subprocess.check_call(command, startupinfo=startupinfo) - out_im = Image.open(outfile) - out_im.load() + with Image.open(outfile) as out_im: + out_im.load() + return out_im.im.copy() finally: try: os.unlink(outfile) @@ -153,47 +168,13 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): except OSError: pass - im = out_im.im.copy() - out_im.close() - return im - - -class PSFile: - """ - Wrapper for bytesio object that treats either CR or LF as end of line. - """ - - def __init__(self, fp): - self.fp = fp - self.char = None - - def seek(self, offset, whence=io.SEEK_SET): - self.char = None - self.fp.seek(offset, whence) - def readline(self): - s = [self.char or b""] - self.char = None - - c = self.fp.read(1) - while (c not in b"\r\n") and len(c): - s.append(c) - c = self.fp.read(1) - - self.char = self.fp.read(1) - # line endings can be 1 or 2 of \r \n, in either order - if self.char in b"\r\n": - self.char = None - - return b"".join(s).decode("latin-1") - - -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5) ## -# Image plugin for Encapsulated PostScript. This plugin supports only +# Image plugin for Encapsulated PostScript. This plugin supports only # a few variants of this format. @@ -205,62 +186,117 @@ class EpsImageFile(ImageFile.ImageFile): mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} - def _open(self): + def _open(self) -> None: (length, offset) = self._find_offset(self.fp) - # Rewrap the open file pointer in something that will - # convert line endings and decode to latin-1. - fp = PSFile(self.fp) - # go to offset - start of "%!PS" - fp.seek(offset) - - box = None - - self.mode = "RGB" - self._size = 1, 1 # FIXME: huh? - - # - # Load EPS header - - s_raw = fp.readline() - s = s_raw.strip("\r\n") - - while s_raw: - if s: - if len(s) > 255: - raise SyntaxError("not an EPS file") - - try: - m = split.match(s) - except re.error as e: - raise SyntaxError("not an EPS file") from e - - if m: - k, v = m.group(1, 2) - self.info[k] = v - if k == "BoundingBox": - try: - # Note: The DSC spec says that BoundingBox - # fields should be integers, but some drivers - # put floating point values there anyway. - box = [int(float(i)) for i in v.split()] - self._size = box[2] - box[0], box[3] - box[1] - self.tile = [ - ("eps", (0, 0) + self.size, offset, (length, box)) - ] - except Exception: - pass + self.fp.seek(offset) + + self._mode = "RGB" + + # When reading header comments, the first comment is used. + # When reading trailer comments, the last comment is used. + bounding_box: list[int] | None = None + imagedata_size: tuple[int, int] | None = None + + byte_arr = bytearray(255) + bytes_mv = memoryview(byte_arr) + bytes_read = 0 + reading_header_comments = True + reading_trailer_comments = False + trailer_reached = False + + def check_required_header_comments() -> None: + """ + The EPS specification requires that some headers exist. + This should be checked when the header comments formally end, + when image data starts, or when the file ends, whichever comes first. + """ + if "PS-Adobe" not in self.info: + msg = 'EPS header missing "%!PS-Adobe" comment' + raise SyntaxError(msg) + if "BoundingBox" not in self.info: + msg = 'EPS header missing "%%BoundingBox" comment' + raise SyntaxError(msg) + + def read_comment(s: str) -> bool: + nonlocal bounding_box, reading_trailer_comments + try: + m = split.match(s) + except re.error as e: + msg = "not an EPS file" + raise SyntaxError(msg) from e + + if not m: + return False + + k, v = m.group(1, 2) + self.info[k] = v + if k == "BoundingBox": + if v == "(atend)": + reading_trailer_comments = True + elif not bounding_box or (trailer_reached and reading_trailer_comments): + try: + # Note: The DSC spec says that BoundingBox + # fields should be integers, but some drivers + # put floating point values there anyway. + bounding_box = [int(float(i)) for i in v.split()] + except Exception: + pass + return True - else: + while True: + byte = self.fp.read(1) + if byte == b"": + # if we didn't read a byte we must be at the end of the file + if bytes_read == 0: + if reading_header_comments: + check_required_header_comments() + break + elif byte in b"\r\n": + # if we read a line ending character, ignore it and parse what + # we have already read. if we haven't read any other characters, + # continue reading + if bytes_read == 0: + continue + else: + # ASCII/hexadecimal lines in an EPS file must not exceed + # 255 characters, not including line ending characters + if bytes_read >= 255: + # only enforce this for lines starting with a "%", + # otherwise assume it's binary data + if byte_arr[0] == ord("%"): + msg = "not an EPS file" + raise SyntaxError(msg) + else: + if reading_header_comments: + check_required_header_comments() + reading_header_comments = False + # reset bytes_read so we can keep reading + # data until the end of the line + bytes_read = 0 + byte_arr[bytes_read] = byte[0] + bytes_read += 1 + continue + + if reading_header_comments: + # Load EPS header + + # if this line doesn't start with a "%", + # or does start with "%%EndComments", + # then we've reached the end of the header/comments + if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments": + check_required_header_comments() + reading_header_comments = False + continue + + s = str(bytes_mv[:bytes_read], "latin-1") + if not read_comment(s): m = field.match(s) if m: k = m.group(1) - - if k == "EndComments": - break if k[:8] == "PS-Adobe": - self.info[k[:8]] = k[9:] + self.info["PS-Adobe"] = k[9:] else: self.info[k] = "" elif s[0] == "%": @@ -268,92 +304,121 @@ def _open(self): # tools mistakenly put in the Comments section pass else: - raise OSError("bad EPS header") - - s_raw = fp.readline() - s = s_raw.strip("\r\n") - - if s and s[:1] != "%": - break - - # - # Scan for an "ImageData" descriptor - - while s[:1] == "%": - - if len(s) > 255: - raise SyntaxError("not an EPS file") - - if s[:11] == "%ImageData:": - # Encoded bitmapped image. - x, y, bi, mo = s[11:].split(None, 7)[:4] - - if int(bi) != 8: - break - try: - self.mode = self.mode_map[int(mo)] - except ValueError: + msg = "bad EPS header" + raise OSError(msg) + elif bytes_mv[:11] == b"%ImageData:": + # Check for an "ImageData" descriptor + # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096 + + # If we've already read an "ImageData" descriptor, + # don't read another one. + if imagedata_size: + bytes_read = 0 + continue + + # Values: + # columns + # rows + # bit depth (1 or 8) + # mode (1: L, 2: LAB, 3: RGB, 4: CMYK) + # number of padding channels + # block size (number of bytes per row per channel) + # binary/ascii (1: binary, 2: ascii) + # data start identifier (the image data follows after a single line + # consisting only of this quoted value) + image_data_values = byte_arr[11:bytes_read].split(None, 7) + columns, rows, bit_depth, mode_id = ( + int(value) for value in image_data_values[:4] + ) + + if bit_depth == 1: + self._mode = "1" + elif bit_depth == 8: + try: + self._mode = self.mode_map[mode_id] + except ValueError: + break + else: break - self._size = int(x), int(y) - return - - s = fp.readline().strip("\r\n") - if not s: + # Parse the columns and rows after checking the bit depth and mode + # in case the bit depth and/or mode are invalid. + imagedata_size = columns, rows + elif bytes_mv[:5] == b"%%EOF": break - - if not box: - raise OSError("cannot determine EPS bounding box") - - def _find_offset(self, fp): - - s = fp.read(160) - - if s[:4] == b"%!PS": + elif trailer_reached and reading_trailer_comments: + # Load EPS trailer + s = str(bytes_mv[:bytes_read], "latin-1") + read_comment(s) + elif bytes_mv[:9] == b"%%Trailer": + trailer_reached = True + bytes_read = 0 + + # A "BoundingBox" is always required, + # even if an "ImageData" descriptor size exists. + if not bounding_box: + msg = "cannot determine EPS bounding box" + raise OSError(msg) + + # An "ImageData" size takes precedence over the "BoundingBox". + self._size = imagedata_size or ( + bounding_box[2] - bounding_box[0], + bounding_box[3] - bounding_box[1], + ) + + self.tile = [ + ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box)) + ] + + def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]: + s = fp.read(4) + + if s == b"%!PS": # for HEAD without binary preview fp.seek(0, io.SEEK_END) length = fp.tell() offset = 0 - elif i32(s, 0) == 0xC6D3D0C5: + elif i32(s) == 0xC6D3D0C5: # FIX for: Some EPS file not handled correctly / issue #302 # EPS can contain binary data # or start directly with latin coding # more info see: # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf - offset = i32(s, 4) - length = i32(s, 8) + s = fp.read(8) + offset = i32(s) + length = i32(s, 4) else: - raise SyntaxError("not an EPS file") + msg = "not an EPS file" + raise SyntaxError(msg) - return (length, offset) + return length, offset - def load(self, scale=1, transparency=False): + def load( + self, scale: int = 1, transparency: bool = False + ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript - if not self.tile: - return - self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) - self.mode = self.im.mode - self._size = self.im.size - self.tile = [] - - def load_seek(self, *args, **kwargs): + if self.tile: + self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) + self._mode = self.im.mode + self._size = self.im.size + self.tile = [] + return Image.Image.load(self) + + def load_seek(self, pos: int) -> None: # we can't incrementally load, so force ImageFile.parser to # use our custom load method by defining this method. pass -# # -------------------------------------------------------------------- -def _save(im, fp, filename, eps=1): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None: """EPS Writer for the Python Imaging Library.""" - # # make sure image data is available im.load() - # # determine PostScript image mode if im.mode == "L": operator = (8, 1, b"image") @@ -362,10 +427,10 @@ def _save(im, fp, filename, eps=1): elif im.mode == "CMYK": operator = (8, 4, b"false 4 colorimage") else: - raise ValueError("image mode is not supported") + msg = "image mode is not supported" + raise ValueError(msg) if eps: - # # write EPS header fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n") fp.write(b"%%Creator: PIL 0.1 EpsEncode\n") @@ -377,7 +442,6 @@ def _save(im, fp, filename, eps=1): fp.write(b"%%ImageData: %d %d " % im.size) fp.write(b'%d %d 0 1 1 "%s"\n' % operator) - # # image header fp.write(b"gsave\n") fp.write(b"10 dict begin\n") @@ -390,7 +454,7 @@ def _save(im, fp, filename, eps=1): if hasattr(fp, "flush"): fp.flush() - ImageFile._save(im, fp, [("eps", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)]) fp.write(b"\n%%%%EndBinary\n") fp.write(b"grestore end\n") @@ -398,7 +462,6 @@ def _save(im, fp, filename, eps=1): fp.flush() -# # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/ExifTags.py b/dependencies/WIN/PIL/ExifTags.py index 7da2dda..2280d5c 100644 --- a/dependencies/WIN/PIL/ExifTags.py +++ b/dependencies/WIN/PIL/ExifTags.py @@ -13,319 +13,370 @@ This module provides constants and clear-text names for various well-known EXIF tags. """ +from __future__ import annotations +from enum import IntEnum -TAGS = { + +class Base(IntEnum): # possibly incomplete - 0x0001: "InteropIndex", - 0x000B: "ProcessingSoftware", - 0x00FE: "NewSubfileType", - 0x00FF: "SubfileType", - 0x0100: "ImageWidth", - 0x0101: "ImageLength", - 0x0102: "BitsPerSample", - 0x0103: "Compression", - 0x0106: "PhotometricInterpretation", - 0x0107: "Thresholding", - 0x0108: "CellWidth", - 0x0109: "CellLength", - 0x010A: "FillOrder", - 0x010D: "DocumentName", - 0x010E: "ImageDescription", - 0x010F: "Make", - 0x0110: "Model", - 0x0111: "StripOffsets", - 0x0112: "Orientation", - 0x0115: "SamplesPerPixel", - 0x0116: "RowsPerStrip", - 0x0117: "StripByteCounts", - 0x0118: "MinSampleValue", - 0x0119: "MaxSampleValue", - 0x011A: "XResolution", - 0x011B: "YResolution", - 0x011C: "PlanarConfiguration", - 0x011D: "PageName", - 0x0120: "FreeOffsets", - 0x0121: "FreeByteCounts", - 0x0122: "GrayResponseUnit", - 0x0123: "GrayResponseCurve", - 0x0124: "T4Options", - 0x0125: "T6Options", - 0x0128: "ResolutionUnit", - 0x0129: "PageNumber", - 0x012D: "TransferFunction", - 0x0131: "Software", - 0x0132: "DateTime", - 0x013B: "Artist", - 0x013C: "HostComputer", - 0x013D: "Predictor", - 0x013E: "WhitePoint", - 0x013F: "PrimaryChromaticities", - 0x0140: "ColorMap", - 0x0141: "HalftoneHints", - 0x0142: "TileWidth", - 0x0143: "TileLength", - 0x0144: "TileOffsets", - 0x0145: "TileByteCounts", - 0x014A: "SubIFDs", - 0x014C: "InkSet", - 0x014D: "InkNames", - 0x014E: "NumberOfInks", - 0x0150: "DotRange", - 0x0151: "TargetPrinter", - 0x0152: "ExtraSamples", - 0x0153: "SampleFormat", - 0x0154: "SMinSampleValue", - 0x0155: "SMaxSampleValue", - 0x0156: "TransferRange", - 0x0157: "ClipPath", - 0x0158: "XClipPathUnits", - 0x0159: "YClipPathUnits", - 0x015A: "Indexed", - 0x015B: "JPEGTables", - 0x015F: "OPIProxy", - 0x0200: "JPEGProc", - 0x0201: "JpegIFOffset", - 0x0202: "JpegIFByteCount", - 0x0203: "JpegRestartInterval", - 0x0205: "JpegLosslessPredictors", - 0x0206: "JpegPointTransforms", - 0x0207: "JpegQTables", - 0x0208: "JpegDCTables", - 0x0209: "JpegACTables", - 0x0211: "YCbCrCoefficients", - 0x0212: "YCbCrSubSampling", - 0x0213: "YCbCrPositioning", - 0x0214: "ReferenceBlackWhite", - 0x02BC: "XMLPacket", - 0x1000: "RelatedImageFileFormat", - 0x1001: "RelatedImageWidth", - 0x1002: "RelatedImageLength", - 0x4746: "Rating", - 0x4749: "RatingPercent", - 0x800D: "ImageID", - 0x828D: "CFARepeatPatternDim", - 0x828E: "CFAPattern", - 0x828F: "BatteryLevel", - 0x8298: "Copyright", - 0x829A: "ExposureTime", - 0x829D: "FNumber", - 0x83BB: "IPTCNAA", - 0x8649: "ImageResources", - 0x8769: "ExifOffset", - 0x8773: "InterColorProfile", - 0x8822: "ExposureProgram", - 0x8824: "SpectralSensitivity", - 0x8825: "GPSInfo", - 0x8827: "ISOSpeedRatings", - 0x8828: "OECF", - 0x8829: "Interlace", - 0x882A: "TimeZoneOffset", - 0x882B: "SelfTimerMode", - 0x8830: "SensitivityType", - 0x8831: "StandardOutputSensitivity", - 0x8832: "RecommendedExposureIndex", - 0x8833: "ISOSpeed", - 0x8834: "ISOSpeedLatitudeyyy", - 0x8835: "ISOSpeedLatitudezzz", - 0x9000: "ExifVersion", - 0x9003: "DateTimeOriginal", - 0x9004: "DateTimeDigitized", - 0x9010: "OffsetTime", - 0x9011: "OffsetTimeOriginal", - 0x9012: "OffsetTimeDigitized", - 0x9101: "ComponentsConfiguration", - 0x9102: "CompressedBitsPerPixel", - 0x9201: "ShutterSpeedValue", - 0x9202: "ApertureValue", - 0x9203: "BrightnessValue", - 0x9204: "ExposureBiasValue", - 0x9205: "MaxApertureValue", - 0x9206: "SubjectDistance", - 0x9207: "MeteringMode", - 0x9208: "LightSource", - 0x9209: "Flash", - 0x920A: "FocalLength", - 0x920B: "FlashEnergy", + InteropIndex = 0x0001 + ProcessingSoftware = 0x000B + NewSubfileType = 0x00FE + SubfileType = 0x00FF + ImageWidth = 0x0100 + ImageLength = 0x0101 + BitsPerSample = 0x0102 + Compression = 0x0103 + PhotometricInterpretation = 0x0106 + Thresholding = 0x0107 + CellWidth = 0x0108 + CellLength = 0x0109 + FillOrder = 0x010A + DocumentName = 0x010D + ImageDescription = 0x010E + Make = 0x010F + Model = 0x0110 + StripOffsets = 0x0111 + Orientation = 0x0112 + SamplesPerPixel = 0x0115 + RowsPerStrip = 0x0116 + StripByteCounts = 0x0117 + MinSampleValue = 0x0118 + MaxSampleValue = 0x0119 + XResolution = 0x011A + YResolution = 0x011B + PlanarConfiguration = 0x011C + PageName = 0x011D + FreeOffsets = 0x0120 + FreeByteCounts = 0x0121 + GrayResponseUnit = 0x0122 + GrayResponseCurve = 0x0123 + T4Options = 0x0124 + T6Options = 0x0125 + ResolutionUnit = 0x0128 + PageNumber = 0x0129 + TransferFunction = 0x012D + Software = 0x0131 + DateTime = 0x0132 + Artist = 0x013B + HostComputer = 0x013C + Predictor = 0x013D + WhitePoint = 0x013E + PrimaryChromaticities = 0x013F + ColorMap = 0x0140 + HalftoneHints = 0x0141 + TileWidth = 0x0142 + TileLength = 0x0143 + TileOffsets = 0x0144 + TileByteCounts = 0x0145 + SubIFDs = 0x014A + InkSet = 0x014C + InkNames = 0x014D + NumberOfInks = 0x014E + DotRange = 0x0150 + TargetPrinter = 0x0151 + ExtraSamples = 0x0152 + SampleFormat = 0x0153 + SMinSampleValue = 0x0154 + SMaxSampleValue = 0x0155 + TransferRange = 0x0156 + ClipPath = 0x0157 + XClipPathUnits = 0x0158 + YClipPathUnits = 0x0159 + Indexed = 0x015A + JPEGTables = 0x015B + OPIProxy = 0x015F + JPEGProc = 0x0200 + JpegIFOffset = 0x0201 + JpegIFByteCount = 0x0202 + JpegRestartInterval = 0x0203 + JpegLosslessPredictors = 0x0205 + JpegPointTransforms = 0x0206 + JpegQTables = 0x0207 + JpegDCTables = 0x0208 + JpegACTables = 0x0209 + YCbCrCoefficients = 0x0211 + YCbCrSubSampling = 0x0212 + YCbCrPositioning = 0x0213 + ReferenceBlackWhite = 0x0214 + XMLPacket = 0x02BC + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageLength = 0x1002 + Rating = 0x4746 + RatingPercent = 0x4749 + ImageID = 0x800D + CFARepeatPatternDim = 0x828D + BatteryLevel = 0x828F + Copyright = 0x8298 + ExposureTime = 0x829A + FNumber = 0x829D + IPTCNAA = 0x83BB + ImageResources = 0x8649 + ExifOffset = 0x8769 + InterColorProfile = 0x8773 + ExposureProgram = 0x8822 + SpectralSensitivity = 0x8824 + GPSInfo = 0x8825 + ISOSpeedRatings = 0x8827 + OECF = 0x8828 + Interlace = 0x8829 + TimeZoneOffset = 0x882A + SelfTimerMode = 0x882B + SensitivityType = 0x8830 + StandardOutputSensitivity = 0x8831 + RecommendedExposureIndex = 0x8832 + ISOSpeed = 0x8833 + ISOSpeedLatitudeyyy = 0x8834 + ISOSpeedLatitudezzz = 0x8835 + ExifVersion = 0x9000 + DateTimeOriginal = 0x9003 + DateTimeDigitized = 0x9004 + OffsetTime = 0x9010 + OffsetTimeOriginal = 0x9011 + OffsetTimeDigitized = 0x9012 + ComponentsConfiguration = 0x9101 + CompressedBitsPerPixel = 0x9102 + ShutterSpeedValue = 0x9201 + ApertureValue = 0x9202 + BrightnessValue = 0x9203 + ExposureBiasValue = 0x9204 + MaxApertureValue = 0x9205 + SubjectDistance = 0x9206 + MeteringMode = 0x9207 + LightSource = 0x9208 + Flash = 0x9209 + FocalLength = 0x920A + Noise = 0x920D + ImageNumber = 0x9211 + SecurityClassification = 0x9212 + ImageHistory = 0x9213 + TIFFEPStandardID = 0x9216 + MakerNote = 0x927C + UserComment = 0x9286 + SubsecTime = 0x9290 + SubsecTimeOriginal = 0x9291 + SubsecTimeDigitized = 0x9292 + AmbientTemperature = 0x9400 + Humidity = 0x9401 + Pressure = 0x9402 + WaterDepth = 0x9403 + Acceleration = 0x9404 + CameraElevationAngle = 0x9405 + XPTitle = 0x9C9B + XPComment = 0x9C9C + XPAuthor = 0x9C9D + XPKeywords = 0x9C9E + XPSubject = 0x9C9F + FlashPixVersion = 0xA000 + ColorSpace = 0xA001 + ExifImageWidth = 0xA002 + ExifImageHeight = 0xA003 + RelatedSoundFile = 0xA004 + ExifInteroperabilityOffset = 0xA005 + FlashEnergy = 0xA20B + SpatialFrequencyResponse = 0xA20C + FocalPlaneXResolution = 0xA20E + FocalPlaneYResolution = 0xA20F + FocalPlaneResolutionUnit = 0xA210 + SubjectLocation = 0xA214 + ExposureIndex = 0xA215 + SensingMethod = 0xA217 + FileSource = 0xA300 + SceneType = 0xA301 + CFAPattern = 0xA302 + CustomRendered = 0xA401 + ExposureMode = 0xA402 + WhiteBalance = 0xA403 + DigitalZoomRatio = 0xA404 + FocalLengthIn35mmFilm = 0xA405 + SceneCaptureType = 0xA406 + GainControl = 0xA407 + Contrast = 0xA408 + Saturation = 0xA409 + Sharpness = 0xA40A + DeviceSettingDescription = 0xA40B + SubjectDistanceRange = 0xA40C + ImageUniqueID = 0xA420 + CameraOwnerName = 0xA430 + BodySerialNumber = 0xA431 + LensSpecification = 0xA432 + LensMake = 0xA433 + LensModel = 0xA434 + LensSerialNumber = 0xA435 + CompositeImage = 0xA460 + CompositeImageCount = 0xA461 + CompositeImageExposureTimes = 0xA462 + Gamma = 0xA500 + PrintImageMatching = 0xC4A5 + DNGVersion = 0xC612 + DNGBackwardVersion = 0xC613 + UniqueCameraModel = 0xC614 + LocalizedCameraModel = 0xC615 + CFAPlaneColor = 0xC616 + CFALayout = 0xC617 + LinearizationTable = 0xC618 + BlackLevelRepeatDim = 0xC619 + BlackLevel = 0xC61A + BlackLevelDeltaH = 0xC61B + BlackLevelDeltaV = 0xC61C + WhiteLevel = 0xC61D + DefaultScale = 0xC61E + DefaultCropOrigin = 0xC61F + DefaultCropSize = 0xC620 + ColorMatrix1 = 0xC621 + ColorMatrix2 = 0xC622 + CameraCalibration1 = 0xC623 + CameraCalibration2 = 0xC624 + ReductionMatrix1 = 0xC625 + ReductionMatrix2 = 0xC626 + AnalogBalance = 0xC627 + AsShotNeutral = 0xC628 + AsShotWhiteXY = 0xC629 + BaselineExposure = 0xC62A + BaselineNoise = 0xC62B + BaselineSharpness = 0xC62C + BayerGreenSplit = 0xC62D + LinearResponseLimit = 0xC62E + CameraSerialNumber = 0xC62F + LensInfo = 0xC630 + ChromaBlurRadius = 0xC631 + AntiAliasStrength = 0xC632 + ShadowScale = 0xC633 + DNGPrivateData = 0xC634 + MakerNoteSafety = 0xC635 + CalibrationIlluminant1 = 0xC65A + CalibrationIlluminant2 = 0xC65B + BestQualityScale = 0xC65C + RawDataUniqueID = 0xC65D + OriginalRawFileName = 0xC68B + OriginalRawFileData = 0xC68C + ActiveArea = 0xC68D + MaskedAreas = 0xC68E + AsShotICCProfile = 0xC68F + AsShotPreProfileMatrix = 0xC690 + CurrentICCProfile = 0xC691 + CurrentPreProfileMatrix = 0xC692 + ColorimetricReference = 0xC6BF + CameraCalibrationSignature = 0xC6F3 + ProfileCalibrationSignature = 0xC6F4 + AsShotProfileName = 0xC6F6 + NoiseReductionApplied = 0xC6F7 + ProfileName = 0xC6F8 + ProfileHueSatMapDims = 0xC6F9 + ProfileHueSatMapData1 = 0xC6FA + ProfileHueSatMapData2 = 0xC6FB + ProfileToneCurve = 0xC6FC + ProfileEmbedPolicy = 0xC6FD + ProfileCopyright = 0xC6FE + ForwardMatrix1 = 0xC714 + ForwardMatrix2 = 0xC715 + PreviewApplicationName = 0xC716 + PreviewApplicationVersion = 0xC717 + PreviewSettingsName = 0xC718 + PreviewSettingsDigest = 0xC719 + PreviewColorSpace = 0xC71A + PreviewDateTime = 0xC71B + RawImageDigest = 0xC71C + OriginalRawFileDigest = 0xC71D + SubTileBlockSize = 0xC71E + RowInterleaveFactor = 0xC71F + ProfileLookTableDims = 0xC725 + ProfileLookTableData = 0xC726 + OpcodeList1 = 0xC740 + OpcodeList2 = 0xC741 + OpcodeList3 = 0xC74E + NoiseProfile = 0xC761 + + +"""Maps EXIF tags to tag names.""" +TAGS = { + **{i.value: i.name for i in Base}, 0x920C: "SpatialFrequencyResponse", - 0x920D: "Noise", - 0x9211: "ImageNumber", - 0x9212: "SecurityClassification", - 0x9213: "ImageHistory", 0x9214: "SubjectLocation", 0x9215: "ExposureIndex", + 0x828E: "CFAPattern", + 0x920B: "FlashEnergy", 0x9216: "TIFF/EPStandardID", - 0x927C: "MakerNote", - 0x9286: "UserComment", - 0x9290: "SubsecTime", - 0x9291: "SubsecTimeOriginal", - 0x9292: "SubsecTimeDigitized", - 0x9400: "AmbientTemperature", - 0x9401: "Humidity", - 0x9402: "Pressure", - 0x9403: "WaterDepth", - 0x9404: "Acceleration", - 0x9405: "CameraElevationAngle", - 0x9C9B: "XPTitle", - 0x9C9C: "XPComment", - 0x9C9D: "XPAuthor", - 0x9C9E: "XPKeywords", - 0x9C9F: "XPSubject", - 0xA000: "FlashPixVersion", - 0xA001: "ColorSpace", - 0xA002: "ExifImageWidth", - 0xA003: "ExifImageHeight", - 0xA004: "RelatedSoundFile", - 0xA005: "ExifInteroperabilityOffset", - 0xA20B: "FlashEnergy", - 0xA20C: "SpatialFrequencyResponse", - 0xA20E: "FocalPlaneXResolution", - 0xA20F: "FocalPlaneYResolution", - 0xA210: "FocalPlaneResolutionUnit", - 0xA214: "SubjectLocation", - 0xA215: "ExposureIndex", - 0xA217: "SensingMethod", - 0xA300: "FileSource", - 0xA301: "SceneType", - 0xA302: "CFAPattern", - 0xA401: "CustomRendered", - 0xA402: "ExposureMode", - 0xA403: "WhiteBalance", - 0xA404: "DigitalZoomRatio", - 0xA405: "FocalLengthIn35mmFilm", - 0xA406: "SceneCaptureType", - 0xA407: "GainControl", - 0xA408: "Contrast", - 0xA409: "Saturation", - 0xA40A: "Sharpness", - 0xA40B: "DeviceSettingDescription", - 0xA40C: "SubjectDistanceRange", - 0xA420: "ImageUniqueID", - 0xA430: "CameraOwnerName", - 0xA431: "BodySerialNumber", - 0xA432: "LensSpecification", - 0xA433: "LensMake", - 0xA434: "LensModel", - 0xA435: "LensSerialNumber", - 0xA460: "CompositeImage", - 0xA461: "CompositeImageCount", - 0xA462: "CompositeImageExposureTimes", - 0xA500: "Gamma", - 0xC4A5: "PrintImageMatching", - 0xC612: "DNGVersion", - 0xC613: "DNGBackwardVersion", - 0xC614: "UniqueCameraModel", - 0xC615: "LocalizedCameraModel", - 0xC616: "CFAPlaneColor", - 0xC617: "CFALayout", - 0xC618: "LinearizationTable", - 0xC619: "BlackLevelRepeatDim", - 0xC61A: "BlackLevel", - 0xC61B: "BlackLevelDeltaH", - 0xC61C: "BlackLevelDeltaV", - 0xC61D: "WhiteLevel", - 0xC61E: "DefaultScale", - 0xC61F: "DefaultCropOrigin", - 0xC620: "DefaultCropSize", - 0xC621: "ColorMatrix1", - 0xC622: "ColorMatrix2", - 0xC623: "CameraCalibration1", - 0xC624: "CameraCalibration2", - 0xC625: "ReductionMatrix1", - 0xC626: "ReductionMatrix2", - 0xC627: "AnalogBalance", - 0xC628: "AsShotNeutral", - 0xC629: "AsShotWhiteXY", - 0xC62A: "BaselineExposure", - 0xC62B: "BaselineNoise", - 0xC62C: "BaselineSharpness", - 0xC62D: "BayerGreenSplit", - 0xC62E: "LinearResponseLimit", - 0xC62F: "CameraSerialNumber", - 0xC630: "LensInfo", - 0xC631: "ChromaBlurRadius", - 0xC632: "AntiAliasStrength", - 0xC633: "ShadowScale", - 0xC634: "DNGPrivateData", - 0xC635: "MakerNoteSafety", - 0xC65A: "CalibrationIlluminant1", - 0xC65B: "CalibrationIlluminant2", - 0xC65C: "BestQualityScale", - 0xC65D: "RawDataUniqueID", - 0xC68B: "OriginalRawFileName", - 0xC68C: "OriginalRawFileData", - 0xC68D: "ActiveArea", - 0xC68E: "MaskedAreas", - 0xC68F: "AsShotICCProfile", - 0xC690: "AsShotPreProfileMatrix", - 0xC691: "CurrentICCProfile", - 0xC692: "CurrentPreProfileMatrix", - 0xC6BF: "ColorimetricReference", - 0xC6F3: "CameraCalibrationSignature", - 0xC6F4: "ProfileCalibrationSignature", - 0xC6F6: "AsShotProfileName", - 0xC6F7: "NoiseReductionApplied", - 0xC6F8: "ProfileName", - 0xC6F9: "ProfileHueSatMapDims", - 0xC6FA: "ProfileHueSatMapData1", - 0xC6FB: "ProfileHueSatMapData2", - 0xC6FC: "ProfileToneCurve", - 0xC6FD: "ProfileEmbedPolicy", - 0xC6FE: "ProfileCopyright", - 0xC714: "ForwardMatrix1", - 0xC715: "ForwardMatrix2", - 0xC716: "PreviewApplicationName", - 0xC717: "PreviewApplicationVersion", - 0xC718: "PreviewSettingsName", - 0xC719: "PreviewSettingsDigest", - 0xC71A: "PreviewColorSpace", - 0xC71B: "PreviewDateTime", - 0xC71C: "RawImageDigest", - 0xC71D: "OriginalRawFileDigest", - 0xC71E: "SubTileBlockSize", - 0xC71F: "RowInterleaveFactor", - 0xC725: "ProfileLookTableDims", - 0xC726: "ProfileLookTableData", - 0xC740: "OpcodeList1", - 0xC741: "OpcodeList2", - 0xC74E: "OpcodeList3", - 0xC761: "NoiseProfile", } -"""Maps EXIF tags to tag names.""" -GPSTAGS = { - 0: "GPSVersionID", - 1: "GPSLatitudeRef", - 2: "GPSLatitude", - 3: "GPSLongitudeRef", - 4: "GPSLongitude", - 5: "GPSAltitudeRef", - 6: "GPSAltitude", - 7: "GPSTimeStamp", - 8: "GPSSatellites", - 9: "GPSStatus", - 10: "GPSMeasureMode", - 11: "GPSDOP", - 12: "GPSSpeedRef", - 13: "GPSSpeed", - 14: "GPSTrackRef", - 15: "GPSTrack", - 16: "GPSImgDirectionRef", - 17: "GPSImgDirection", - 18: "GPSMapDatum", - 19: "GPSDestLatitudeRef", - 20: "GPSDestLatitude", - 21: "GPSDestLongitudeRef", - 22: "GPSDestLongitude", - 23: "GPSDestBearingRef", - 24: "GPSDestBearing", - 25: "GPSDestDistanceRef", - 26: "GPSDestDistance", - 27: "GPSProcessingMethod", - 28: "GPSAreaInformation", - 29: "GPSDateStamp", - 30: "GPSDifferential", - 31: "GPSHPositioningError", -} +class GPS(IntEnum): + GPSVersionID = 0x00 + GPSLatitudeRef = 0x01 + GPSLatitude = 0x02 + GPSLongitudeRef = 0x03 + GPSLongitude = 0x04 + GPSAltitudeRef = 0x05 + GPSAltitude = 0x06 + GPSTimeStamp = 0x07 + GPSSatellites = 0x08 + GPSStatus = 0x09 + GPSMeasureMode = 0x0A + GPSDOP = 0x0B + GPSSpeedRef = 0x0C + GPSSpeed = 0x0D + GPSTrackRef = 0x0E + GPSTrack = 0x0F + GPSImgDirectionRef = 0x10 + GPSImgDirection = 0x11 + GPSMapDatum = 0x12 + GPSDestLatitudeRef = 0x13 + GPSDestLatitude = 0x14 + GPSDestLongitudeRef = 0x15 + GPSDestLongitude = 0x16 + GPSDestBearingRef = 0x17 + GPSDestBearing = 0x18 + GPSDestDistanceRef = 0x19 + GPSDestDistance = 0x1A + GPSProcessingMethod = 0x1B + GPSAreaInformation = 0x1C + GPSDateStamp = 0x1D + GPSDifferential = 0x1E + GPSHPositioningError = 0x1F + + """Maps EXIF GPS tags to tag names.""" +GPSTAGS = {i.value: i.name for i in GPS} + + +class Interop(IntEnum): + InteropIndex = 0x0001 + InteropVersion = 0x0002 + RelatedImageFileFormat = 0x1000 + RelatedImageWidth = 0x1001 + RelatedImageHeight = 0x1002 + + +class IFD(IntEnum): + Exif = 0x8769 + GPSInfo = 0x8825 + MakerNote = 0x927C + Makernote = 0x927C # Deprecated + Interop = 0xA005 + IFD1 = -1 + + +class LightSource(IntEnum): + Unknown = 0x00 + Daylight = 0x01 + Fluorescent = 0x02 + Tungsten = 0x03 + Flash = 0x04 + Fine = 0x09 + Cloudy = 0x0A + Shade = 0x0B + DaylightFluorescent = 0x0C + DayWhiteFluorescent = 0x0D + CoolWhiteFluorescent = 0x0E + WhiteFluorescent = 0x0F + StandardLightA = 0x11 + StandardLightB = 0x12 + StandardLightC = 0x13 + D55 = 0x14 + D65 = 0x15 + D75 = 0x16 + D50 = 0x17 + ISO = 0x18 + Other = 0xFF diff --git a/dependencies/WIN/PIL/FitsImagePlugin.py b/dependencies/WIN/PIL/FitsImagePlugin.py new file mode 100644 index 0000000..6bbd264 --- /dev/null +++ b/dependencies/WIN/PIL/FitsImagePlugin.py @@ -0,0 +1,152 @@ +# +# The Python Imaging Library +# $Id$ +# +# FITS file handling +# +# Copyright (c) 1998-2003 by Fredrik Lundh +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import gzip +import math + +from . import Image, ImageFile + + +def _accept(prefix: bytes) -> bool: + return prefix[:6] == b"SIMPLE" + + +class FitsImageFile(ImageFile.ImageFile): + format = "FITS" + format_description = "FITS" + + def _open(self) -> None: + assert self.fp is not None + + headers: dict[bytes, bytes] = {} + header_in_progress = False + decoder_name = "" + while True: + header = self.fp.read(80) + if not header: + msg = "Truncated FITS file" + raise OSError(msg) + keyword = header[:8].strip() + if keyword in (b"SIMPLE", b"XTENSION"): + header_in_progress = True + elif headers and not header_in_progress: + # This is now a data unit + break + elif keyword == b"END": + # Seek to the end of the header unit + self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880) + if not decoder_name: + decoder_name, offset, args = self._parse_headers(headers) + + header_in_progress = False + continue + + if decoder_name: + # Keep going to read past the headers + continue + + value = header[8:].split(b"/")[0].strip() + if value.startswith(b"="): + value = value[1:].strip() + if not headers and (not _accept(keyword) or value != b"T"): + msg = "Not a FITS file" + raise SyntaxError(msg) + headers[keyword] = value + + if not decoder_name: + msg = "No image data" + raise ValueError(msg) + + offset += self.fp.tell() - 80 + self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)] + + def _get_size( + self, headers: dict[bytes, bytes], prefix: bytes + ) -> tuple[int, int] | None: + naxis = int(headers[prefix + b"NAXIS"]) + if naxis == 0: + return None + + if naxis == 1: + return 1, int(headers[prefix + b"NAXIS1"]) + else: + return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"]) + + def _parse_headers( + self, headers: dict[bytes, bytes] + ) -> tuple[str, int, tuple[str | int, ...]]: + prefix = b"" + decoder_name = "raw" + offset = 0 + if ( + headers.get(b"XTENSION") == b"'BINTABLE'" + and headers.get(b"ZIMAGE") == b"T" + and headers[b"ZCMPTYPE"] == b"'GZIP_1 '" + ): + no_prefix_size = self._get_size(headers, prefix) or (0, 0) + number_of_bits = int(headers[b"BITPIX"]) + offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8) + + prefix = b"Z" + decoder_name = "fits_gzip" + + size = self._get_size(headers, prefix) + if not size: + return "", 0, () + + self._size = size + + number_of_bits = int(headers[prefix + b"BITPIX"]) + if number_of_bits == 8: + self._mode = "L" + elif number_of_bits == 16: + self._mode = "I;16" + elif number_of_bits == 32: + self._mode = "I" + elif number_of_bits in (-32, -64): + self._mode = "F" + + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) + return decoder_name, offset, args + + +class FitsGzipDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + value = gzip.decompress(self.fd.read()) + + rows = [] + offset = 0 + number_of_bits = min(self.args[0] // 8, 4) + for y in range(self.state.ysize): + row = bytearray() + for x in range(self.state.xsize): + row += value[offset + (4 - number_of_bits) : offset + 4] + offset += 4 + rows.append(row) + self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row])) + return -1, 0 + + +# -------------------------------------------------------------------- +# Registry + +Image.register_open(FitsImageFile.format, FitsImageFile, _accept) +Image.register_decoder("fits_gzip", FitsGzipDecoder) + +Image.register_extensions(FitsImageFile.format, [".fit", ".fits"]) diff --git a/dependencies/WIN/PIL/FitsStubImagePlugin.py b/dependencies/WIN/PIL/FitsStubImagePlugin.py deleted file mode 100644 index a3a94cf..0000000 --- a/dependencies/WIN/PIL/FitsStubImagePlugin.py +++ /dev/null @@ -1,100 +0,0 @@ -# -# The Python Imaging Library -# $Id$ -# -# FITS stub adapter -# -# Copyright (c) 1998-2003 by Fredrik Lundh -# -# See the README file for information on usage and redistribution. -# - -from . import Image, ImageFile - -_handler = None - - -def register_handler(handler): - """ - Install application-specific FITS image handler. - - :param handler: Handler object. - """ - global _handler - _handler = handler - - -# -------------------------------------------------------------------- -# Image adapter - - -def _accept(prefix): - return prefix[:6] == b"SIMPLE" - - -class FITSStubImageFile(ImageFile.StubImageFile): - - format = "FITS" - format_description = "FITS" - - def _open(self): - offset = self.fp.tell() - - headers = {} - while True: - header = self.fp.read(80) - if not header: - raise OSError("Truncated FITS file") - keyword = header[:8].strip() - if keyword == b"END": - break - value = header[8:].strip() - if value.startswith(b"="): - value = value[1:].strip() - if not headers and (not _accept(keyword) or value != b"T"): - raise SyntaxError("Not a FITS file") - headers[keyword] = value - - naxis = int(headers[b"NAXIS"]) - if naxis == 0: - raise ValueError("No image data") - elif naxis == 1: - self._size = 1, int(headers[b"NAXIS1"]) - else: - self._size = int(headers[b"NAXIS1"]), int(headers[b"NAXIS2"]) - - number_of_bits = int(headers[b"BITPIX"]) - if number_of_bits == 8: - self.mode = "L" - elif number_of_bits == 16: - self.mode = "I" - # rawmode = "I;16S" - elif number_of_bits == 32: - self.mode = "I" - elif number_of_bits in (-32, -64): - self.mode = "F" - # rawmode = "F" if number_of_bits == -32 else "F;64F" - - self.fp.seek(offset) - - loader = self._load() - if loader: - loader.open(self) - - def _load(self): - return _handler - - -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("FITS save handler not installed") - _handler.save(im, fp, filename) - - -# -------------------------------------------------------------------- -# Registry - -Image.register_open(FITSStubImageFile.format, FITSStubImageFile, _accept) -Image.register_save(FITSStubImageFile.format, _save) - -Image.register_extensions(FITSStubImageFile.format, [".fit", ".fits"]) diff --git a/dependencies/WIN/PIL/FliImagePlugin.py b/dependencies/WIN/PIL/FliImagePlugin.py index f2d4857..b534b30 100644 --- a/dependencies/WIN/PIL/FliImagePlugin.py +++ b/dependencies/WIN/PIL/FliImagePlugin.py @@ -14,7 +14,9 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import os from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -25,8 +27,12 @@ # decoder -def _accept(prefix): - return len(prefix) >= 6 and i16(prefix, 4) in [0xAF11, 0xAF12] +def _accept(prefix: bytes) -> bool: + return ( + len(prefix) >= 6 + and i16(prefix, 4) in [0xAF11, 0xAF12] + and i16(prefix, 14) in [0, 3] # flags + ) ## @@ -35,28 +41,23 @@ def _accept(prefix): class FliImageFile(ImageFile.ImageFile): - format = "FLI" format_description = "Autodesk FLI/FLC Animation" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # HEAD s = self.fp.read(128) - if not ( - _accept(s) - and i16(s, 14) in [0, 3] # flags - and s[20:22] == b"\x00\x00" # reserved - ): - raise SyntaxError("not an FLI/FLC file") + if not (_accept(s) and s[20:22] == b"\x00\x00"): + msg = "not an FLI/FLC file" + raise SyntaxError(msg) # frames self.n_frames = i16(s, 6) self.is_animated = self.n_frames > 1 # image characteristics - self.mode = "P" + self._mode = "P" self._size = i16(s, 8), i16(s, 10) # animation speed @@ -76,26 +77,36 @@ def _open(self): if i16(s, 4) == 0xF100: # prefix chunk; ignore it self.__offset = self.__offset + i32(s) + self.fp.seek(self.__offset) s = self.fp.read(16) if i16(s, 4) == 0xF1FA: # look for palette chunk - s = self.fp.read(6) - if i16(s, 4) == 11: - self._palette(palette, 2) - elif i16(s, 4) == 4: - self._palette(palette, 0) - - palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette] - self.palette = ImagePalette.raw("RGB", b"".join(palette)) + number_of_subchunks = i16(s, 6) + chunk_size: int | None = None + for _ in range(number_of_subchunks): + if chunk_size is not None: + self.fp.seek(chunk_size - 6, os.SEEK_CUR) + s = self.fp.read(6) + chunk_type = i16(s, 4) + if chunk_type in (4, 11): + self._palette(palette, 2 if chunk_type == 11 else 0) + break + chunk_size = i32(s) + if not chunk_size: + break + + self.palette = ImagePalette.raw( + "RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette) + ) # set things up to decode first frame self.__frame = -1 - self.__fp = self.fp + self._fp = self.fp self.__rewind = self.fp.tell() self.seek(0) - def _palette(self, palette, shift): + def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None: # load palette i = 0 @@ -113,7 +124,7 @@ def _palette(self, palette, shift): palette[i] = (r, g, b) i += 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: @@ -122,46 +133,39 @@ def seek(self, frame): for f in range(self.__frame + 1, frame + 1): self._seek(f) - def _seek(self, frame): + def _seek(self, frame: int) -> None: if frame == 0: self.__frame = -1 - self.__fp.seek(self.__rewind) + self._fp.seek(self.__rewind) self.__offset = 128 else: # ensure that the previous frame was loaded self.load() if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) self.__frame = frame # move to next frame - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self.__offset) s = self.fp.read(4) if not s: - raise EOFError + msg = "missing frame size" + raise EOFError(msg) framesize = i32(s) self.decodermaxblock = framesize - self.tile = [("fli", (0, 0) + self.size, self.__offset, None)] + self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)] self.__offset += framesize - def tell(self): + def tell(self) -> int: return self.__frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # registry diff --git a/dependencies/WIN/PIL/FontFile.py b/dependencies/WIN/PIL/FontFile.py index c5fc80b..1e0c1c1 100644 --- a/dependencies/WIN/PIL/FontFile.py +++ b/dependencies/WIN/PIL/FontFile.py @@ -13,16 +13,19 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os +from typing import BinaryIO from . import Image, _binary WIDTH = 800 -def puti16(fp, values): +def puti16( + fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int] +) -> None: """Write network order (big-endian) 16-bit sequence""" for v in values: if v < 0: @@ -33,17 +36,32 @@ def puti16(fp, values): class FontFile: """Base class for raster font file handlers.""" - bitmap = None - - def __init__(self): - - self.info = {} - self.glyph = [None] * 256 - - def __getitem__(self, ix): + bitmap: Image.Image | None = None + + def __init__(self) -> None: + self.info: dict[bytes, bytes | int] = {} + self.glyph: list[ + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ] = [None] * 256 + + def __getitem__(self, ix: int) -> ( + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ): return self.glyph[ix] - def compile(self): + def compile(self) -> None: """Create metrics and bitmap""" if self.bitmap: @@ -52,7 +70,7 @@ def compile(self): # create bitmap large enough to hold all data h = w = maxwidth = 0 lines = 1 - for glyph in self: + for glyph in self.glyph: if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) @@ -66,20 +84,22 @@ def compile(self): ysize = lines * h if xsize == 0 and ysize == 0: - return "" + return self.ysize = h # paste glyphs into bitmap self.bitmap = Image.new("1", (xsize, ysize)) - self.metrics = [None] * 256 + self.metrics: list[ + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]] + | None + ] = [None] * 256 x = y = 0 for i in range(256): glyph = self[i] if glyph: d, dst, src, im = glyph xx = src[2] - src[0] - # yy = src[3] - src[1] x0, y0 = x, y x = x + xx if x > WIDTH: @@ -90,12 +110,15 @@ def compile(self): self.bitmap.paste(im.crop(src), s) self.metrics[i] = d, dst, s - def save(self, filename): + def save(self, filename: str) -> None: """Save font""" self.compile() # font data + if not self.bitmap: + msg = "No bitmap created" + raise ValueError(msg) self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG") # font metrics @@ -106,6 +129,6 @@ def save(self, filename): for id in range(256): m = self.metrics[id] if not m: - puti16(fp, [0] * 10) + puti16(fp, (0,) * 10) else: puti16(fp, m[0] + m[1] + m[2]) diff --git a/dependencies/WIN/PIL/FpxImagePlugin.py b/dependencies/WIN/PIL/FpxImagePlugin.py index 5e38546..4cfcb06 100644 --- a/dependencies/WIN/PIL/FpxImagePlugin.py +++ b/dependencies/WIN/PIL/FpxImagePlugin.py @@ -14,6 +14,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import olefile from . import Image, ImageFile @@ -22,7 +24,7 @@ # we map from colour field tuples to (mode, rawmode) descriptors MODES = { # opacity - (0x00007FFE): ("A", "L"), + (0x00007FFE,): ("A", "L"), # monochrome (0x00010000,): ("L", "L"), (0x00018000, 0x00017FFE): ("RGBA", "LA"), @@ -39,7 +41,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == olefile.MAGIC @@ -48,11 +50,10 @@ def _accept(prefix): class FpxImageFile(ImageFile.ImageFile): - format = "FPX" format_description = "FlashPix" - def _open(self): + def _open(self) -> None: # # read the OLE directory and see if this is a likely # to be a FlashPix file @@ -60,14 +61,17 @@ def _open(self): try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an FPX file; invalid OLE file") from e + msg = "not an FPX file; invalid OLE file" + raise SyntaxError(msg) from e - if self.ole.root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": - raise SyntaxError("not an FPX file; bad root CLSID") + root = self.ole.root + if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B": + msg = "not an FPX file; bad root CLSID" + raise SyntaxError(msg) self._open_index(1) - def _open_index(self, index=1): + def _open_index(self, index: int = 1) -> None: # # get the Image Contents Property Set @@ -77,12 +81,14 @@ def _open_index(self, index=1): # size (highest resolution) + assert isinstance(prop[0x1000002], int) + assert isinstance(prop[0x1000003], int) self._size = prop[0x1000002], prop[0x1000003] size = max(self.size) i = 1 while size > 64: - size = size / 2 + size = size // 2 i += 1 self.maxid = i - 1 @@ -96,15 +102,14 @@ def _open_index(self, index=1): s = prop[0x2000002 | id] - colors = [] - bands = i32(s, 4) - if bands > 4: - raise OSError("Invalid number of bands") - for i in range(bands): - # note: for now, we ignore the "uncalibrated" flag - colors.append(i32(s, 8 + i * 4) & 0x7FFFFFFF) + if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4: + msg = "Invalid number of bands" + raise OSError(msg) - self.mode, self.rawmode = MODES[tuple(colors)] + # note: for now, we ignore the "uncalibrated" flag + colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands)) + + self._mode, self.rawmode = MODES[colors] # load JPEG tables, if any self.jpeg = {} @@ -115,7 +120,7 @@ def _open_index(self, index=1): self._open_subimage(1, self.maxid) - def _open_subimage(self, index=1, subimage=0): + def _open_subimage(self, index: int = 1, subimage: int = 0) -> None: # # setup tile descriptors for a given subimage @@ -141,7 +146,8 @@ def _open_subimage(self, index=1, subimage=0): length = i32(s, 32) if size != self.size: - raise OSError("subimage mismatch") + msg = "subimage mismatch" + raise OSError(msg) # get tile descriptors fp.seek(28 + offset) @@ -153,33 +159,33 @@ def _open_subimage(self, index=1, subimage=0): self.tile = [] for i in range(0, len(s), length): + x1 = min(xsize, x + xtile) + y1 = min(ysize, y + ytile) compression = i32(s, i + 8) if compression == 0: self.tile.append( - ( + ImageFile._Tile( "raw", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, - (self.rawmode), + self.rawmode, ) ) elif compression == 1: - # FIXME: the fill decoder is not implemented self.tile.append( - ( + ImageFile._Tile( "fill", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, (self.rawmode, s[12:16]), ) ) elif compression == 2: - internal_color_conversion = s[14] jpeg_tables = s[15] rawmode = self.rawmode @@ -199,9 +205,9 @@ def _open_subimage(self, index=1, subimage=0): jpegmode = rawmode self.tile.append( - ( + ImageFile._Tile( "jpeg", - (x, y, x + xtile, y + ytile), + (x, y, x1, y1), i32(s, i) + 28, (rawmode, jpegmode), ) @@ -214,7 +220,8 @@ def _open_subimage(self, index=1, subimage=0): self.tile_prefix = self.jpeg[jpeg_tables] else: - raise OSError("unknown/invalid compression") + msg = "unknown/invalid compression" + raise OSError(msg) x = x + xtile if x >= xsize: @@ -223,15 +230,23 @@ def _open_subimage(self, index=1, subimage=0): break # isn't really required self.stream = stream + self._fp = self.fp self.fp = None - def load(self): - + def load(self) -> Image.core.PixelAccess | None: if not self.fp: self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"]) return ImageFile.ImageFile.load(self) + def close(self) -> None: + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.ole.close() + super().__exit__() + # # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/FtexImagePlugin.py b/dependencies/WIN/PIL/FtexImagePlugin.py index 3b16903..0516b76 100644 --- a/dependencies/WIN/PIL/FtexImagePlugin.py +++ b/dependencies/WIN/PIL/FtexImagePlugin.py @@ -51,27 +51,35 @@ Note: All data is stored in little-Endian (Intel) byte order. """ +from __future__ import annotations + import struct +from enum import IntEnum from io import BytesIO from . import Image, ImageFile MAGIC = b"FTEX" -FORMAT_DXT1 = 0 -FORMAT_UNCOMPRESSED = 1 + + +class Format(IntEnum): + DXT1 = 0 + UNCOMPRESSED = 1 class FtexImageFile(ImageFile.ImageFile): format = "FTEX" format_description = "Texture File Format (IW2:EOC)" - def _open(self): - struct.unpack(" None: + if not _accept(self.fp.read(4)): + msg = "not an FTEX file" + raise SyntaxError(msg) struct.unpack(" None: pass -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == MAGIC diff --git a/dependencies/WIN/PIL/GbrImagePlugin.py b/dependencies/WIN/PIL/GbrImagePlugin.py index 0f23060..f319d7e 100644 --- a/dependencies/WIN/PIL/GbrImagePlugin.py +++ b/dependencies/WIN/PIL/GbrImagePlugin.py @@ -23,12 +23,13 @@ # Version 2 files are saved by GIMP v2.8 (at least) # Version 3 files have a format specifier of 18 for 16bit floats in # the color depth field. This is currently unsupported by Pillow. +from __future__ import annotations from . import Image, ImageFile from ._binary import i32be as i32 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2) @@ -37,25 +38,28 @@ def _accept(prefix): class GbrImageFile(ImageFile.ImageFile): - format = "GBR" format_description = "GIMP brush file" - def _open(self): + def _open(self) -> None: header_size = i32(self.fp.read(4)) - version = i32(self.fp.read(4)) if header_size < 20: - raise SyntaxError("not a GIMP brush") + msg = "not a GIMP brush" + raise SyntaxError(msg) + version = i32(self.fp.read(4)) if version not in (1, 2): - raise SyntaxError(f"Unsupported GIMP brush version: {version}") + msg = f"Unsupported GIMP brush version: {version}" + raise SyntaxError(msg) width = i32(self.fp.read(4)) height = i32(self.fp.read(4)) color_depth = i32(self.fp.read(4)) if width <= 0 or height <= 0: - raise SyntaxError("not a GIMP brush") + msg = "not a GIMP brush" + raise SyntaxError(msg) if color_depth not in (1, 4): - raise SyntaxError(f"Unsupported GIMP brush color depth: {color_depth}") + msg = f"Unsupported GIMP brush color depth: {color_depth}" + raise SyntaxError(msg) if version == 1: comment_length = header_size - 20 @@ -63,15 +67,16 @@ def _open(self): comment_length = header_size - 28 magic_number = self.fp.read(4) if magic_number != b"GIMP": - raise SyntaxError("not a GIMP brush, bad magic number") + msg = "not a GIMP brush, bad magic number" + raise SyntaxError(msg) self.info["spacing"] = i32(self.fp.read(4)) comment = self.fp.read(comment_length)[:-1] if color_depth == 1: - self.mode = "L" + self._mode = "L" else: - self.mode = "RGBA" + self._mode = "RGBA" self._size = width, height @@ -83,13 +88,11 @@ def _open(self): # Data is an uncompressed block of w * h * bytes/pixel self._data_size = width * height * color_depth - def load(self): - if self.im: - # Already loaded - return - - self.im = Image.core.new(self.mode, self.size) - self.frombytes(self.fp.read(self._data_size)) + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self._data_size)) + return Image.Image.load(self) # diff --git a/dependencies/WIN/PIL/GdImageFile.py b/dependencies/WIN/PIL/GdImageFile.py index 9c34ada..fc4801e 100644 --- a/dependencies/WIN/PIL/GdImageFile.py +++ b/dependencies/WIN/PIL/GdImageFile.py @@ -25,11 +25,14 @@ class is not registered for use with :py:func:`PIL.Image.open()`. To open a implementation is provided for convenience and demonstrational purposes only. """ +from __future__ import annotations +from typing import IO from . import ImageFile, ImagePalette, UnidentifiedImageError from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._typing import StrOrBytesPath class GdImageFile(ImageFile.ImageFile): @@ -43,48 +46,57 @@ class GdImageFile(ImageFile.ImageFile): format = "GD" format_description = "GD uncompressed images" - def _open(self): - + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(1037) - if not i16(s) in [65534, 65535]: - raise SyntaxError("Not a valid GD 2.x .gd file") + if i16(s) not in [65534, 65535]: + msg = "Not a valid GD 2.x .gd file" + raise SyntaxError(msg) - self.mode = "L" # FIXME: "P" + self._mode = "L" # FIXME: "P" self._size = i16(s, 2), i16(s, 4) - trueColor = s[6] - trueColorOffset = 2 if trueColor else 0 + true_color = s[6] + true_color_offset = 2 if true_color else 0 # transparency index - tindex = i32(s, 7 + trueColorOffset) + tindex = i32(s, 7 + true_color_offset) if tindex < 256: self.info["transparency"] = tindex self.palette = ImagePalette.raw( - "XBGR", s[7 + trueColorOffset + 4 : 7 + trueColorOffset + 4 + 256 * 4] + "XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4] ) self.tile = [ - ("raw", (0, 0) + self.size, 7 + trueColorOffset + 4 + 256 * 4, ("L", 0, 1)) + ImageFile._Tile( + "raw", + (0, 0) + self.size, + 7 + true_color_offset + 4 + 256 * 4, + "L", + ) ] -def open(fp, mode="r"): +def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile: """ Load texture from a GD image file. - :param filename: GD file name, or an opened file handle. + :param fp: GD file name, or an opened file handle. :param mode: Optional mode. In this version, if the mode argument is given, it must be "r". :returns: An image instance. :raises OSError: If the image could not be read. """ if mode != "r": - raise ValueError("bad mode") + msg = "bad mode" + raise ValueError(msg) try: return GdImageFile(fp) except SyntaxError as e: - raise UnidentifiedImageError("cannot identify this image file") from e + msg = "cannot identify this image file" + raise UnidentifiedImageError(msg) from e diff --git a/dependencies/WIN/PIL/GifImagePlugin.py b/dependencies/WIN/PIL/GifImagePlugin.py index 128afc4..47022d5 100644 --- a/dependencies/WIN/PIL/GifImagePlugin.py +++ b/dependencies/WIN/PIL/GifImagePlugin.py @@ -23,22 +23,50 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import math import os import subprocess - -from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence +from enum import IntEnum +from functools import cached_property +from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, Union + +from . import ( + Image, + ImageChops, + ImageFile, + ImageMath, + ImageOps, + ImagePalette, + ImageSequence, +) from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + from ._typing import Buffer + + +class LoadingStrategy(IntEnum): + """.. versionadded:: 9.1.0""" + + RGB_AFTER_FIRST = 0 + RGB_AFTER_DIFFERENT_PALETTE_ONLY = 1 + RGB_ALWAYS = 2 + + +#: .. versionadded:: 9.1.0 +LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST + # -------------------------------------------------------------------- # Identify/read GIF files -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] in [b"GIF87a", b"GIF89a"] @@ -48,29 +76,33 @@ def _accept(prefix): class GifImageFile(ImageFile.ImageFile): - format = "GIF" format_description = "Compuserve GIF" _close_exclusive_fp_after_loading = False global_palette = None - def data(self): + def data(self) -> bytes | None: s = self.fp.read(1) if s and s[0]: return self.fp.read(s[0]) return None - def _open(self): + def _is_palette_needed(self, p: bytes) -> bool: + for i in range(0, len(p), 3): + if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): + return True + return False + def _open(self) -> None: # Screen s = self.fp.read(13) if not _accept(s): - raise SyntaxError("not a GIF file") + msg = "not a GIF file" + raise SyntaxError(msg) self.info["version"] = s[:6] self._size = i16(s, 6), i16(s, 8) - self.tile = [] flags = s[10] bits = (flags & 7) + 1 @@ -79,53 +111,50 @@ def _open(self): self.info["background"] = s[11] # check if palette contains colour indices p = self.fp.read(3 << bits) - for i in range(0, len(p), 3): - if not (i // 3 == p[i] == p[i + 1] == p[i + 2]): - p = ImagePalette.raw("RGB", p) - self.global_palette = self.palette = p - break + if self._is_palette_needed(p): + p = ImagePalette.raw("RGB", p) + self.global_palette = self.palette = p - self.__fp = self.fp # FIXME: hack + self._fp = self.fp # FIXME: hack self.__rewind = self.fp.tell() - self._n_frames = None - self._is_animated = None + self._n_frames: int | None = None self._seek(0) # get ready to read first frame @property - def n_frames(self): + def n_frames(self) -> int: if self._n_frames is None: current = self.tell() try: while True: - self.seek(self.tell() + 1) + self._seek(self.tell() + 1, False) except EOFError: self._n_frames = self.tell() + 1 self.seek(current) return self._n_frames - @property - def is_animated(self): - if self._is_animated is None: - if self._n_frames is not None: - self._is_animated = self._n_frames != 1 - else: - current = self.tell() + @cached_property + def is_animated(self) -> bool: + if self._n_frames is not None: + return self._n_frames != 1 - try: - self.seek(1) - self._is_animated = True - except EOFError: - self._is_animated = False + current = self.tell() + if current: + return True - self.seek(current) - return self._is_animated + try: + self._seek(1, False) + is_animated = True + except EOFError: + is_animated = False - def seek(self, frame): + self.seek(current) + return is_animated + + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: - if frame != 0: - self.im = None + self._im = None self._seek(0) last_frame = self.__frame @@ -134,30 +163,29 @@ def seek(self, frame): self._seek(f) except EOFError as e: self.seek(last_frame) - raise EOFError("no more images in GIF file") from e - - def _seek(self, frame): + msg = "no more images in GIF file" + raise EOFError(msg) from e + def _seek(self, frame: int, update_image: bool = True) -> None: if frame == 0: # rewind self.__offset = 0 - self.dispose = None - self.dispose_extent = [0, 0, 0, 0] # x0, y0, x1, y1 + self.dispose: _imaging.ImagingCore | None = None self.__frame = -1 - self.__fp.seek(self.__rewind) + self._fp.seek(self.__rewind) self.disposal_method = 0 + if "comment" in self.info: + del self.info["comment"] else: # ensure that the previous frame was loaded - if self.tile: + if self.tile and update_image: self.load() if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") - self.__frame = frame + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) - self.tile = [] - - self.fp = self.__fp + self.fp = self._fp if self.__offset: # backup to last frame self.fp.seek(self.__offset) @@ -165,19 +193,20 @@ def _seek(self, frame): pass self.__offset = 0 - if self.dispose: - self.im.paste(self.dispose, self.dispose_extent) - - from copy import copy + s = self.fp.read(1) + if not s or s == b";": + msg = "no more images in GIF file" + raise EOFError(msg) - self.palette = copy(self.global_palette) + palette: ImagePalette.ImagePalette | Literal[False] | None = None - info = {} + info: dict[str, Any] = {} frame_transparency = None interlace = None + frame_dispose_extent = None while True: - - s = self.fp.read(1) + if not s: + s = self.fp.read(1) if not s or s == b";": break @@ -187,7 +216,7 @@ def _seek(self, frame): # s = self.fp.read(1) block = self.data() - if s[0] == 249: + if s[0] == 249 and block is not None: # # graphic control extension # @@ -209,22 +238,29 @@ def _seek(self, frame): # # comment extension # + comment = b"" + + # Read this comment block while block: - if "comment" in info: - info["comment"] += block - else: - info["comment"] = block + comment += block block = self.data() + + if "comment" in info: + # If multiple comment blocks in frame, separate with \n + info["comment"] += b"\n" + comment + else: + info["comment"] = comment + s = None continue - elif s[0] == 255: + elif s[0] == 255 and frame == 0 and block is not None: # # application extension # info["extension"] = block, self.fp.tell() if block[:11] == b"NETSCAPE2.0": block = self.data() - if len(block) >= 3 and block[0] == 1: - info["loop"] = i16(block, 1) + if block and len(block) >= 3 and block[0] == 1: + self.info["loop"] = i16(block, 1) while self.data(): pass @@ -237,107 +273,224 @@ def _seek(self, frame): # extent x0, y0 = i16(s, 0), i16(s, 2) x1, y1 = x0 + i16(s, 4), y0 + i16(s, 6) - if x1 > self.size[0] or y1 > self.size[1]: + if (x1 > self.size[0] or y1 > self.size[1]) and update_image: self._size = max(x1, self.size[0]), max(y1, self.size[1]) - self.dispose_extent = x0, y0, x1, y1 + Image._decompression_bomb_check(self._size) + frame_dispose_extent = x0, y0, x1, y1 flags = s[8] interlace = (flags & 64) != 0 if flags & 128: bits = (flags & 7) + 1 - self.palette = ImagePalette.raw("RGB", self.fp.read(3 << bits)) + p = self.fp.read(3 << bits) + if self._is_palette_needed(p): + palette = ImagePalette.raw("RGB", p) + else: + palette = False # image data bits = self.fp.read(1)[0] self.__offset = self.fp.tell() break + s = None + + if interlace is None: + msg = "image not found in GIF frame" + raise EOFError(msg) + + self.__frame = frame + if not update_image: + return + + self.tile = [] + if self.dispose: + self.im.paste(self.dispose, self.dispose_extent) + + self._frame_palette = palette if palette is not None else self.global_palette + self._frame_transparency = frame_transparency + if frame == 0: + if self._frame_palette: + if LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + self._mode = "RGBA" if frame_transparency is not None else "RGB" + else: + self._mode = "P" else: - pass - # raise OSError, "illegal GIF tag `%x`" % s[0] + self._mode = "L" - try: - if self.disposal_method < 2: - # do not dispose or none specified - self.dispose = None - elif self.disposal_method == 2: - # replace with background colour - - # only dispose the extent in this frame - x0, y0, x1, y1 = self.dispose_extent - dispose_size = (x1 - x0, y1 - y0) - - Image._decompression_bomb_check(dispose_size) - - # by convention, attempt to use transparency first - color = self.info.get("transparency", frame_transparency) - if color is None: - color = self.info.get("background", 0) - self.dispose = Image.core.fill("P", dispose_size, color) + if palette: + self.palette = palette + elif self.global_palette: + from copy import copy + + self.palette = copy(self.global_palette) + else: + self.palette = None + else: + if self.mode == "P": + if ( + LOADING_STRATEGY != LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY + or palette + ): + if "transparency" in self.info: + self.im.putpalettealpha(self.info["transparency"], 0) + self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG) + self._mode = "RGBA" + del self.info["transparency"] + else: + self._mode = "RGB" + self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG) + + def _rgb(color: int) -> tuple[int, int, int]: + if self._frame_palette: + if color * 3 + 3 > len(self._frame_palette.palette): + color = 0 + return tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]) else: - # replace with previous contents - if self.im: + return (color, color, color) + + self.dispose = None + self.dispose_extent = frame_dispose_extent + if self.dispose_extent and self.disposal_method >= 2: + try: + if self.disposal_method == 2: + # replace with background colour + # only dispose the extent in this frame - self.dispose = self._crop(self.im, self.dispose_extent) - elif frame_transparency is not None: x0, y0, x1, y1 = self.dispose_extent dispose_size = (x1 - x0, y1 - y0) Image._decompression_bomb_check(dispose_size) - self.dispose = Image.core.fill( - "P", dispose_size, frame_transparency - ) - except AttributeError: - pass + + # by convention, attempt to use transparency first + dispose_mode = "P" + color = self.info.get("transparency", frame_transparency) + if color is not None: + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(color) + (0,) + else: + color = self.info.get("background", 0) + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGB" + color = _rgb(color) + self.dispose = Image.core.fill(dispose_mode, dispose_size, color) + else: + # replace with previous contents + if self._im is not None: + # only dispose the extent in this frame + self.dispose = self._crop(self.im, self.dispose_extent) + elif frame_transparency is not None: + x0, y0, x1, y1 = self.dispose_extent + dispose_size = (x1 - x0, y1 - y0) + + Image._decompression_bomb_check(dispose_size) + dispose_mode = "P" + color = frame_transparency + if self.mode in ("RGB", "RGBA"): + dispose_mode = "RGBA" + color = _rgb(frame_transparency) + (0,) + self.dispose = Image.core.fill( + dispose_mode, dispose_size, color + ) + except AttributeError: + pass if interlace is not None: transparency = -1 if frame_transparency is not None: if frame == 0: - self.info["transparency"] = frame_transparency - else: + if LOADING_STRATEGY != LoadingStrategy.RGB_ALWAYS: + self.info["transparency"] = frame_transparency + elif self.mode not in ("RGB", "RGBA"): transparency = frame_transparency self.tile = [ - ( + ImageFile._Tile( "gif", (x0, y0, x1, y1), self.__offset, (bits, interlace, transparency), ) ] - else: - # self.__fp = None - raise EOFError - for k in ["duration", "comment", "extension", "loop"]: + if info.get("comment"): + self.info["comment"] = info["comment"] + for k in ["duration", "extension"]: if k in info: self.info[k] = info[k] elif k in self.info: del self.info[k] - self.mode = "L" - if self.palette: - self.mode = "P" + def load_prepare(self) -> None: + temp_mode = "P" if self._frame_palette else "L" + self._prev_im = None + if self.__frame == 0: + if self._frame_transparency is not None: + self.im = Image.core.fill( + temp_mode, self.size, self._frame_transparency + ) + elif self.mode in ("RGB", "RGBA"): + self._prev_im = self.im + if self._frame_palette: + self.im = Image.core.fill("P", self.size, self._frame_transparency or 0) + self.im.putpalette("RGB", *self._frame_palette.getdata()) + else: + self._im = None + if not self._prev_im and self._im is not None and self.size != self.im.size: + expanded_im = Image.core.fill(self.im.mode, self.size) + if self._frame_palette: + expanded_im.putpalette("RGB", *self._frame_palette.getdata()) + expanded_im.paste(self.im, (0, 0) + self.im.size) + + self.im = expanded_im + self._mode = temp_mode + self._frame_palette = None + + super().load_prepare() + + def load_end(self) -> None: + if self.__frame == 0: + if self.mode == "P" and LOADING_STRATEGY == LoadingStrategy.RGB_ALWAYS: + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + self._mode = "RGBA" + else: + self._mode = "RGB" + self.im = self.im.convert(self.mode, Image.Dither.FLOYDSTEINBERG) + return + if not self._prev_im: + return + if self.size != self._prev_im.size: + if self._frame_transparency is not None: + expanded_im = Image.core.fill("RGBA", self.size) + else: + expanded_im = Image.core.fill("P", self.size) + expanded_im.putpalette("RGB", "RGB", self.im.getpalette()) + expanded_im = expanded_im.convert("RGB") + expanded_im.paste(self._prev_im, (0, 0) + self._prev_im.size) + + self._prev_im = expanded_im + assert self._prev_im is not None + if self._frame_transparency is not None: + self.im.putpalettealpha(self._frame_transparency, 0) + frame_im = self.im.convert("RGBA") + else: + frame_im = self.im.convert("RGB") - def load_prepare(self): - if not self.im and "transparency" in self.info: - self.im = Image.core.fill(self.mode, self.size, self.info["transparency"]) + assert self.dispose_extent is not None + frame_im = self._crop(frame_im, self.dispose_extent) - super(GifImageFile, self).load_prepare() + self.im = self._prev_im + self._mode = self.im.mode + if frame_im.mode == "RGBA": + self.im.paste(frame_im, self.dispose_extent, frame_im) + else: + self.im.paste(frame_im, self.dispose_extent) - def tell(self): + def tell(self) -> int: return self.__frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # -------------------------------------------------------------------- # Write GIF files @@ -346,7 +499,7 @@ def _close__fp(self): RAWMODE = {"1": "L", "L": "L", "P": "P"} -def _normalize_mode(im, initial_call=False): +def _normalize_mode(im: Image.Image) -> Image.Image: """ Takes an image (or frame), returns an image in a mode that is appropriate for saving in a Gif. @@ -354,29 +507,30 @@ def _normalize_mode(im, initial_call=False): It may return the original image, or it may return an image converted to palette or 'L' mode. - UNDONE: What is the point of mucking with the initial call palette, for - an image that shouldn't have a palette, or it would be a mode 'P' and - get returned in the RAWMODE clause. - :param im: Image object - :param initial_call: Default false, set to true for a single frame. :returns: Image object """ if im.mode in RAWMODE: im.load() return im if Image.getmodebase(im.mode) == "RGB": - if initial_call: - palette_size = 256 - if im.palette: - palette_size = len(im.palette.getdata()[1]) // 3 - return im.convert("P", palette=Image.ADAPTIVE, colors=palette_size) - else: - return im.convert("P") + im = im.convert("P", palette=Image.Palette.ADAPTIVE) + assert im.palette is not None + if im.palette.mode == "RGBA": + for rgba in im.palette.colors: + if rgba[3] == 0: + im.info["transparency"] = im.palette.colors[rgba] + break + return im return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, list[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -398,19 +552,22 @@ def _normalize_palette(im, palette, info): if im.mode == "P": if not source_palette: - source_palette = im.im.getpalette("RGB")[:768] + im_palette = im.getpalette(None) + assert im_palette is not None + source_palette = bytearray(im_palette) else: # L-mode if not source_palette: source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + assert source_palette is not None if palette: - used_palette_colors = [] + used_palette_colors: list[int | None] = [] + assert im.palette is not None for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) - try: - index = im.palette.colors[source_color] - except KeyError: + index = im.palette.colors.get(source_color) + if index in used_palette_colors: index = None used_palette_colors.append(index) for i, index in enumerate(used_palette_colors): @@ -419,20 +576,38 @@ def _normalize_palette(im, palette, info): if j not in used_palette_colors: used_palette_colors[i] = j break - im = im.remap_palette(used_palette_colors) + dest_map: list[int] = [] + for index in used_palette_colors: + assert index is not None + dest_map.append(index) + im = im.remap_palette(dest_map) else: - used_palette_colors = _get_optimize(im, info) - if used_palette_colors is not None: - return im.remap_palette(used_palette_colors, source_palette) + optimized_palette_colors = _get_optimize(im, info) + if optimized_palette_colors is not None: + im = im.remap_palette(optimized_palette_colors, source_palette) + if "transparency" in info: + try: + info["transparency"] = optimized_palette_colors.index( + info["transparency"] + ) + except ValueError: + del info["transparency"] + return im + assert im.palette is not None im.palette.palette = source_palette return im -def _write_single_frame(im, fp, palette): - im_out = _normalize_mode(im, True) +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: _Palette | None, +) -> None: + im_out = _normalize_mode(im) for k, v in im_out.info.items(): - im.encoderinfo.setdefault(k, v) + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) im_out = _normalize_palette(im_out, palette, im.encoderinfo) for s in _get_global_header(im_out, im.encoderinfo): @@ -445,17 +620,40 @@ def _write_single_frame(im, fp, palette): _write_local_header(fp, im, (0, 0), flags) im_out.encoderconfig = (8, get_interlace(im)) - ImageFile._save(im_out, fp, [("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])]) + ImageFile._save( + im_out, fp, [ImageFile._Tile("gif", (0, 0) + im.size, 0, RAWMODE[im_out.mode])] + ) fp.write(b"\0") # end of image data -def _write_multiple_frames(im, fp, palette): +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: + palette_bytes = [ + bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame) + ] + if palette_bytes[0] != palette_bytes[1]: + im_frame = im_frame.convert("RGBA") + base_im = base_im.convert("RGBA") + delta = ImageChops.subtract_modulo(im_frame, base_im) + return delta, delta.getbbox(alpha_only=False) + + +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + - duration = im.encoderinfo.get("duration", im.info.get("duration")) +def _write_multiple_frames( + im: Image.Image, fp: IO[bytes], palette: _Palette | None +) -> bool: + duration = im.encoderinfo.get("duration") disposal = im.encoderinfo.get("disposal", im.info.get("disposal")) - im_frames = [] + im_frames: list[_Frame] = [] + previous_im: Image.Image | None = None frame_count = 0 background_im = None for imSequence in itertools.chain([im], im.encoderinfo.get("append_images", [])): @@ -464,81 +662,127 @@ def _write_multiple_frames(im, fp, palette): im_frame = _normalize_mode(im_frame.copy()) if frame_count == 0: for k, v in im_frame.info.items(): - im.encoderinfo.setdefault(k, v) - im_frame = _normalize_palette(im_frame, palette, im.encoderinfo) + if k == "transparency": + continue + if isinstance(k, str): + im.encoderinfo.setdefault(k, v) encoderinfo = im.encoderinfo.copy() + if "transparency" in im_frame.info: + encoderinfo.setdefault("transparency", im_frame.info["transparency"]) + im_frame = _normalize_palette(im_frame, palette, encoderinfo) if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] if isinstance(disposal, (list, tuple)): encoderinfo["disposal"] = disposal[frame_count] frame_count += 1 - if im_frames: + diff_frame = None + if im_frames and previous_im: # delta frame - previous = im_frames[-1] - if encoderinfo.get("disposal") == 2: + delta, bbox = _getbbox(previous_im, im_frame) + if not bbox: + # This frame is identical to the previous frame + if encoderinfo.get("duration"): + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] + continue + if im_frames[-1].encoderinfo.get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) ) background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) - base_im = background_im - else: - base_im = previous["im"] - if _get_palette_bytes(im_frame) == _get_palette_bytes(base_im): - delta = ImageChops.subtract_modulo(im_frame, base_im) - else: - delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") - ) - bbox = delta.getbbox() - if not bbox: - # This frame is identical to the previous frame - if duration: - previous["encoderinfo"]["duration"] += encoderinfo["duration"] - continue + first_palette = im_frames[0].im.palette + assert first_palette is not None + background_im.putpalette(first_palette, first_palette.mode) + bbox = _getbbox(background_im, im_frame)[1] + elif encoderinfo.get("optimize") and im_frame.mode != "1": + if "transparency" not in encoderinfo: + assert im_frame.palette is not None + try: + encoderinfo["transparency"] = ( + im_frame.palette._new_color_index(im_frame) + ) + except ValueError: + pass + if "transparency" in encoderinfo: + # When the delta is zero, fill the image with transparency + diff_frame = im_frame.copy() + fill = Image.new("P", delta.size, encoderinfo["transparency"]) + if delta.mode == "RGBA": + r, g, b, a = delta.split() + mask = ImageMath.lambda_eval( + lambda args: args["convert"]( + args["max"]( + args["max"]( + args["max"](args["r"], args["g"]), args["b"] + ), + args["a"], + ) + * 255, + "1", + ), + r=r, + g=g, + b=b, + a=a, + ) + else: + if delta.mode == "P": + # Convert to L without considering palette + delta_l = Image.new("L", delta.size) + delta_l.putdata(delta.getdata()) + delta = delta_l + mask = ImageMath.lambda_eval( + lambda args: args["convert"](args["im"] * 255, "1"), + im=delta, + ) + diff_frame.paste(fill, mask=ImageOps.invert(mask)) else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) - - if len(im_frames) > 1: - for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: - # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): - fp.write(s) - offset = (0, 0) - else: - # compress difference - if not palette: - frame_data["encoderinfo"]["include_color_table"] = True - - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) - return True - elif "duration" in im.encoderinfo and isinstance( - im.encoderinfo["duration"], (list, tuple) - ): - # Since multiple frames will not be written, add together the frame durations - im.encoderinfo["duration"] = sum(im.encoderinfo["duration"]) + previous_im = im_frame + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1: + if "duration" in im.encoderinfo: + # Since multiple frames will not be written, use the combined duration + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False + + for frame_data in im_frames: + im_frame = frame_data.im + if not frame_data.bbox: + # global header + for s in _get_global_header(im_frame, frame_data.encoderinfo): + fp.write(s) + offset = (0, 0) + else: + # compress difference + if not palette: + frame_data.encoderinfo["include_color_table"] = True + + im_frame = im_frame.crop(frame_data.bbox) + offset = frame_data.bbox[:2] + _write_frame_data(fp, im_frame, offset, frame_data.encoderinfo) + return True -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) -def _save(im, fp, filename, save_all=False): +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: # header if "palette" in im.encoderinfo or "palette" in im.info: palette = im.encoderinfo.get("palette", im.info.get("palette")) else: palette = None - im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True) + im.encoderinfo.setdefault("optimize", True) if not save_all or not _write_multiple_frames(im, fp, palette): _write_single_frame(im, fp, palette) @@ -549,7 +793,7 @@ def _save(im, fp, filename, save_all=False): fp.flush() -def get_interlace(im): +def get_interlace(im: Image.Image) -> int: interlace = im.encoderinfo.get("interlace", 1) # workaround for @PIL153 @@ -559,24 +803,13 @@ def get_interlace(im): return interlace -def _write_local_header(fp, im, offset, flags): - transparent_color_exists = False +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: - pass - else: - transparency = int(transparency) - # optimize the block away if transparent color is not used - transparent_color_exists = True - - used_palette_colors = _get_optimize(im, im.encoderinfo) - if used_palette_colors is not None: - # adjust the transparency index after optimize - try: - transparency = used_palette_colors.index(transparency) - except ValueError: - transparent_color_exists = False + transparency = None if "duration" in im.encoderinfo: duration = int(im.encoderinfo["duration"] / 10) @@ -585,11 +818,9 @@ def _write_local_header(fp, im, offset, flags): disposal = int(im.encoderinfo.get("disposal", 0)) - if transparent_color_exists or duration != 0 or disposal: - packed_flag = 1 if transparent_color_exists else 0 + if transparency is not None or duration != 0 or disposal: + packed_flag = 1 if transparency is not None else 0 packed_flag |= disposal << 2 - if not transparent_color_exists: - transparency = 0 fp.write( b"!" @@ -597,31 +828,10 @@ def _write_local_header(fp, im, offset, flags): + o8(4) # length + o8(packed_flag) # packed fields + o16(duration) # duration - + o8(transparency) # transparency index + + o8(transparency or 0) # transparency index + o8(0) ) - if "comment" in im.encoderinfo and 1 <= len(im.encoderinfo["comment"]): - fp.write(b"!" + o8(254)) # extension intro - comment = im.encoderinfo["comment"] - if isinstance(comment, str): - comment = comment.encode() - for i in range(0, len(comment), 255): - subblock = comment[i : i + 255] - fp.write(o8(len(subblock)) + subblock) - fp.write(o8(0)) - if "loop" in im.encoderinfo: - number_of_loops = im.encoderinfo["loop"] - fp.write( - b"!" - + o8(255) # extension intro - + o8(11) - + b"NETSCAPE2.0" - + o8(3) - + o8(1) - + o16(number_of_loops) # number of loops - + o8(0) - ) include_color_table = im.encoderinfo.get("include_color_table") if include_color_table: palette_bytes = _get_palette_bytes(im) @@ -643,8 +853,7 @@ def _write_local_header(fp, im, offset, flags): fp.write(o8(8)) # bits -def _save_netpbm(im, fp, filename): - +def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -675,6 +884,7 @@ def _save_netpbm(im, fp, filename): ) # Allow ppmquant to receive SIGPIPE if ppmtogif exits + assert quant_proc.stdout is not None quant_proc.stdout.close() retcode = quant_proc.wait() @@ -696,7 +906,7 @@ def _save_netpbm(im, fp, filename): _FORCE_OPTIMIZE = False -def _get_optimize(im, info): +def _get_optimize(im: Image.Image, info: dict[str, Any]) -> list[int] | None: """ Palette optimization is a potentially expensive operation. @@ -707,7 +917,7 @@ def _get_optimize(im, info): :param info: encoderinfo :returns: list of indexes of palette entries in use, or None """ - if im.mode in ("P", "L") and info and info.get("optimize", 0): + if im.mode in ("P", "L") and info and info.get("optimize"): # Potentially expensive operation. # The palette saves 3 bytes per color not used, but palette @@ -726,14 +936,25 @@ def _get_optimize(im, info): if count: used_palette_colors.append(i) - if optimise or ( - len(used_palette_colors) <= 128 - and max(used_palette_colors) > len(used_palette_colors) + if optimise or max(used_palette_colors) >= len(used_palette_colors): + return used_palette_colors + + assert im.palette is not None + num_palette_colors = len(im.palette.palette) // Image.getmodebands( + im.palette.mode + ) + current_palette_size = 1 << (num_palette_colors - 1).bit_length() + if ( + # check that the palette would become smaller when saved + len(used_palette_colors) <= current_palette_size // 2 + # check that the palette is not already the smallest possible size + and current_palette_size > 2 ): return used_palette_colors + return None -def _get_color_table_size(palette_bytes): +def _get_color_table_size(palette_bytes: bytes) -> int: # calculate the palette size for the header if not palette_bytes: return 0 @@ -743,7 +964,7 @@ def _get_color_table_size(palette_bytes): return math.ceil(math.log(len(palette_bytes) // 3, 2)) - 1 -def _get_header_palette(palette_bytes): +def _get_header_palette(palette_bytes: bytes) -> bytes: """ Returns the palette, null padded to the next power of 2 (*3) bytes suitable for direct inclusion in the GIF header @@ -761,61 +982,73 @@ def _get_header_palette(palette_bytes): return palette_bytes -def _get_palette_bytes(im): +def _get_palette_bytes(im: Image.Image) -> bytes: """ Gets the palette for inclusion in the gif header :param im: Image object :returns: Bytes, len<=768 suitable for inclusion in gif header """ - return im.palette.palette + if not im.palette: + return b"" + palette = bytes(im.palette.palette) + if im.palette.mode == "RGBA": + palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3)) + return palette -def _get_background(im, infoBackground): + +def _get_background( + im: Image.Image, + info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None, +) -> int: background = 0 - if infoBackground: - background = infoBackground - if isinstance(background, tuple): + if info_background: + if isinstance(info_background, tuple): # WebPImagePlugin stores an RGBA value in info["background"] # So it must be converted to the same format as GifImagePlugin's # info["background"] - a global color table index + assert im.palette is not None try: - background = im.palette.getcolor(background, im) + background = im.palette.getcolor(info_background, im) except ValueError as e: - if str(e) == "cannot allocate more than 256 colors": + if str(e) not in ( # If all 256 colors are in use, # then there is no need for the background color - return 0 - else: + "cannot allocate more than 256 colors", + # Ignore non-opaque WebP background + "cannot add non-opaque RGBA color to RGB palette", + ): raise + else: + background = info_background return background -def _get_global_header(im, info): +def _get_global_header(im: Image.Image, info: dict[str, Any]) -> list[bytes]: """Return a list of strings representing a GIF header""" # Header Block # https://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp version = b"87a" - for extensionKey in ["transparency", "duration", "loop", "comment"]: - if info and extensionKey in info: - if (extensionKey == "duration" and info[extensionKey] == 0) or ( - extensionKey == "comment" and not (1 <= len(info[extensionKey]) <= 255) - ): - continue - version = b"89a" - break - else: - if im.info.get("version") == b"89a": - version = b"89a" + if im.info.get("version") == b"89a" or ( + info + and ( + "transparency" in info + or info.get("loop") is not None + or info.get("duration") + or info.get("comment") + ) + ): + version = b"89a" background = _get_background(im, info.get("background")) palette_bytes = _get_palette_bytes(im) color_table_size = _get_color_table_size(palette_bytes) - return [ + header = [ b"GIF" # signature + version # version + o16(im.size[0]) # canvas width @@ -828,9 +1061,38 @@ def _get_global_header(im, info): # Global Color Table _get_header_palette(palette_bytes), ] + if info.get("loop") is not None: + header.append( + b"!" + + o8(255) # extension intro + + o8(11) + + b"NETSCAPE2.0" + + o8(3) + + o8(1) + + o16(info["loop"]) # number of loops + + o8(0) + ) + if info.get("comment"): + comment_block = b"!" + o8(254) # extension intro + comment = info["comment"] + if isinstance(comment, str): + comment = comment.encode() + for i in range(0, len(comment), 255): + subblock = comment[i : i + 255] + comment_block += o8(len(subblock)) + subblock + + comment_block += o8(0) + header.append(comment_block) + return header -def _write_frame_data(fp, im_frame, offset, params): + +def _write_frame_data( + fp: IO[bytes], + im_frame: Image.Image, + offset: tuple[int, int], + params: dict[str, Any], +) -> None: try: im_frame.encoderinfo = params @@ -838,7 +1100,9 @@ def _write_frame_data(fp, im_frame, offset, params): _write_local_header(fp, im_frame, offset, 0) ImageFile._save( - im_frame, fp, [("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])] + im_frame, + fp, + [ImageFile._Tile("gif", (0, 0) + im_frame.size, 0, RAWMODE[im_frame.mode])], ) fp.write(b"\0") # end of image data @@ -850,7 +1114,9 @@ def _write_frame_data(fp, im_frame, offset, params): # Legacy GIF utilities -def getheader(im, palette=None, info=None): +def getheader( + im: Image.Image, palette: _Palette | None = None, info: dict[str, Any] | None = None +) -> tuple[list[bytes], list[int] | None]: """ Legacy Method to get Gif data from image. @@ -862,11 +1128,11 @@ def getheader(im, palette=None, info=None): :returns: tuple of(list of header items, optimized palette) """ - used_palette_colors = _get_optimize(im, info) - if info is None: info = {} + used_palette_colors = _get_optimize(im, info) + if "background" not in info and "background" in im.info: info["background"] = im.info["background"] @@ -878,9 +1144,9 @@ def getheader(im, palette=None, info=None): return header, used_palette_colors -# To specify duration, add the time in milliseconds to getdata(), -# e.g. getdata(im_frame, duration=1000) -def getdata(im, offset=(0, 0), **params): +def getdata( + im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any +) -> list[bytes]: """ Legacy Method @@ -888,18 +1154,23 @@ def getdata(im, offset=(0, 0), **params): The first string is a local image header, the rest contains encoded image data. + To specify duration, add the time in milliseconds, + e.g. ``getdata(im_frame, duration=1000)`` + :param im: Image object - :param offset: Tuple of (x, y) pixels. Defaults to (0,0) - :param \\**params: E.g. duration or other encoder info parameters - :returns: List of Bytes containing gif encoded frame data + :param offset: Tuple of (x, y) pixels. Defaults to (0, 0) + :param \\**params: e.g. duration or other encoder info parameters + :returns: List of bytes containing GIF encoded frame data """ + from io import BytesIO - class Collector: + class Collector(BytesIO): data = [] - def write(self, data): + def write(self, data: Buffer) -> int: self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/dependencies/WIN/PIL/GimpGradientFile.py b/dependencies/WIN/PIL/GimpGradientFile.py index 7ab7f99..220eac5 100644 --- a/dependencies/WIN/PIL/GimpGradientFile.py +++ b/dependencies/WIN/PIL/GimpGradientFile.py @@ -18,9 +18,10 @@ the corresponding code in GIMP, written by Federico Mena Quintero. See the GIMP distribution for more information.) """ - +from __future__ import annotations from math import log, pi, sin, sqrt +from typing import IO, Callable from ._binary import o8 @@ -28,7 +29,7 @@ """""" # Enable auto-doc for data member -def linear(middle, pos): +def linear(middle: float, pos: float) -> float: if pos <= middle: if middle < EPSILON: return 0.0 @@ -43,19 +44,19 @@ def linear(middle, pos): return 0.5 + 0.5 * pos / middle -def curved(middle, pos): +def curved(middle: float, pos: float) -> float: return pos ** (log(0.5) / log(max(middle, EPSILON))) -def sine(middle, pos): +def sine(middle: float, pos: float) -> float: return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 -def sphere_increasing(middle, pos): +def sphere_increasing(middle: float, pos: float) -> float: return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) -def sphere_decreasing(middle, pos): +def sphere_decreasing(middle: float, pos: float) -> float: return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) @@ -64,18 +65,28 @@ def sphere_decreasing(middle, pos): class GradientFile: - - gradient = None - - def getpalette(self, entries=256): - + gradient: ( + list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] + | None + ) = None + + def getpalette(self, entries: int = 256) -> tuple[bytes, str]: + assert self.gradient is not None palette = [] ix = 0 x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix] for i in range(entries): - x = i / (entries - 1) while x1 < x: @@ -104,10 +115,10 @@ def getpalette(self, entries=256): class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" - def __init__(self, fp): - + def __init__(self, fp: IO[bytes]) -> None: if fp.readline()[:13] != b"GIMP Gradient": - raise SyntaxError("not a GIMP gradient file") + msg = "not a GIMP gradient file" + raise SyntaxError(msg) line = fp.readline() @@ -117,10 +128,9 @@ def __init__(self, fp): count = int(line) - gradient = [] + self.gradient = [] for i in range(count): - s = fp.readline().split() w = [float(x) for x in s[:11]] @@ -133,8 +143,7 @@ def __init__(self, fp): cspace = int(s[12]) if cspace != 0: - raise OSError("cannot handle HSV colour space") - - gradient.append((x0, x1, xm, rgb0, rgb1, segment)) + msg = "cannot handle HSV colour space" + raise OSError(msg) - self.gradient = gradient + self.gradient.append((x0, x1, xm, rgb0, rgb1, segment)) diff --git a/dependencies/WIN/PIL/GimpPaletteFile.py b/dependencies/WIN/PIL/GimpPaletteFile.py index 10fd3ad..4cad0eb 100644 --- a/dependencies/WIN/PIL/GimpPaletteFile.py +++ b/dependencies/WIN/PIL/GimpPaletteFile.py @@ -13,8 +13,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from typing import IO from ._binary import o8 @@ -24,33 +26,33 @@ class GimpPaletteFile: rawmode = "RGB" - def __init__(self, fp): - - self.palette = [o8(i) * 3 for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] if fp.readline()[:12] != b"GIMP Palette": - raise SyntaxError("not a GIMP palette file") + msg = "not a GIMP palette file" + raise SyntaxError(msg) for i in range(256): - s = fp.readline() if not s: break # skip fields and comment lines - if re.match(br"\w+:|#", s): + if re.match(rb"\w+:|#", s): continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = tuple(map(int, s.split()[:3])) if len(v) != 3: - raise ValueError("bad palette entry") - - self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) + msg = "bad palette entry" + raise ValueError(msg) - self.palette = b"".join(self.palette) + palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) - def getpalette(self): + self.palette = b"".join(palette) + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/dependencies/WIN/PIL/GribStubImagePlugin.py b/dependencies/WIN/PIL/GribStubImagePlugin.py index b9bdd16..e9aa084 100644 --- a/dependencies/WIN/PIL/GribStubImagePlugin.py +++ b/dependencies/WIN/PIL/GribStubImagePlugin.py @@ -8,13 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific GRIB image handler. @@ -28,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): - return prefix[0:4] == b"GRIB" and prefix[7] == 1 +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"GRIB" and prefix[7] == 1 class GribStubImageFile(ImageFile.StubImageFile): - format = "GRIB" format_description = "GRIB" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not a GRIB file") + msg = "Not a GRIB file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("GRIB save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "GRIB save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/WIN/PIL/Hdf5StubImagePlugin.py b/dependencies/WIN/PIL/Hdf5StubImagePlugin.py index 362f2d3..cc9e73d 100644 --- a/dependencies/WIN/PIL/Hdf5StubImagePlugin.py +++ b/dependencies/WIN/PIL/Hdf5StubImagePlugin.py @@ -8,13 +8,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from . import Image, ImageFile _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific HDF5 image handler. @@ -28,39 +31,39 @@ def register_handler(handler): # Image adapter -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == b"\x89HDF\r\n\x1a\n" class HDF5StubImageFile(ImageFile.StubImageFile): - format = "HDF5" format_description = "HDF5" - def _open(self): - + def _open(self) -> None: offset = self.fp.tell() if not _accept(self.fp.read(8)): - raise SyntaxError("Not an HDF file") + msg = "Not an HDF file" + raise SyntaxError(msg) self.fp.seek(offset) # make something up - self.mode = "F" + self._mode = "F" self._size = 1, 1 loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler -def _save(im, fp, filename): - if _handler is None or not hasattr("_handler", "save"): - raise OSError("HDF5 save handler not installed") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if _handler is None or not hasattr(_handler, "save"): + msg = "HDF5 save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/WIN/PIL/IcnsImagePlugin.py b/dependencies/WIN/PIL/IcnsImagePlugin.py index d30eaf9..9757b2b 100644 --- a/dependencies/WIN/PIL/IcnsImagePlugin.py +++ b/dependencies/WIN/PIL/IcnsImagePlugin.py @@ -16,37 +16,45 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import os import struct import sys +from typing import IO -from PIL import Image, ImageFile, PngImagePlugin, features +from . import Image, ImageFile, PngImagePlugin, features +from ._deprecate import deprecate enable_jpeg2k = features.check_codec("jpg_2000") if enable_jpeg2k: - from PIL import Jpeg2KImagePlugin + from . import Jpeg2KImagePlugin MAGIC = b"icns" HEADERSIZE = 8 -def nextheader(fobj): +def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]: return struct.unpack(">4sI", fobj.read(HEADERSIZE)) -def read_32t(fobj, start_length, size): +def read_32t( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # The 128x128 icon seems to have an extra header for some reason. (start, length) = start_length fobj.seek(start) sig = fobj.read(4) if sig != b"\x00\x00\x00\x00": - raise SyntaxError("Unknown signature, expecting 0x00000000") + msg = "Unknown signature, expecting 0x00000000" + raise SyntaxError(msg) return read_32(fobj, (start + 4, length - 4), size) -def read_32(fobj, start_length, size): +def read_32( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: """ Read a 32bit RGB icon resource. Seems to be either uncompressed or an RLE packbits-like scheme. @@ -69,26 +77,29 @@ def read_32(fobj, start_length, size): byte = fobj.read(1) if not byte: break - byte = byte[0] - if byte & 0x80: - blocksize = byte - 125 + byte_int = byte[0] + if byte_int & 0x80: + blocksize = byte_int - 125 byte = fobj.read(1) for i in range(blocksize): data.append(byte) else: - blocksize = byte + 1 + blocksize = byte_int + 1 data.append(fobj.read(blocksize)) bytesleft -= blocksize if bytesleft <= 0: break if bytesleft != 0: - raise SyntaxError(f"Error reading channel [{repr(bytesleft)} left]") + msg = f"Error reading channel [{repr(bytesleft)} left]" + raise SyntaxError(msg) band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1) im.im.putband(band.im, band_ix) return {"RGB": im} -def read_mk(fobj, start_length, size): +def read_mk( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: # Alpha masks seem to be uncompressed start = start_length[0] fobj.seek(start) @@ -98,10 +109,14 @@ def read_mk(fobj, start_length, size): return {"A": band} -def read_png_or_jpeg2000(fobj, start_length, size): +def read_png_or_jpeg2000( + fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int] +) -> dict[str, Image.Image]: (start, length) = start_length fobj.seek(start) sig = fobj.read(12) + + im: Image.Image if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a": fobj.seek(start) im = PngImagePlugin.PngImageFile(fobj) @@ -113,10 +128,11 @@ def read_png_or_jpeg2000(fobj, start_length, size): or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" ): if not enable_jpeg2k: - raise ValueError( + msg = ( "Unsupported icon subimage format (rebuild PIL " "with JPEG 2000 support to fix this)" ) + raise ValueError(msg) # j2k, jpc or j2c fobj.seek(start) jp2kstream = fobj.read(length) @@ -127,11 +143,11 @@ def read_png_or_jpeg2000(fobj, start_length, size): im = im.convert("RGBA") return {"RGBA": im} else: - raise ValueError("Unsupported icon subimage format") + msg = "Unsupported icon subimage format" + raise ValueError(msg) class IcnsFile: - SIZES = { (512, 512, 2): [(b"ic10", read_png_or_jpeg2000)], (512, 512, 1): [(b"ic09", read_png_or_jpeg2000)], @@ -159,43 +175,46 @@ class IcnsFile: ], } - def __init__(self, fobj): + def __init__(self, fobj: IO[bytes]) -> None: """ fobj is a file-like object as an icns resource """ # signature : (start, length) - self.dct = dct = {} + self.dct = {} self.fobj = fobj sig, filesize = nextheader(fobj) - if sig != MAGIC: - raise SyntaxError("not an icns file") + if not _accept(sig): + msg = "not an icns file" + raise SyntaxError(msg) i = HEADERSIZE while i < filesize: sig, blocksize = nextheader(fobj) if blocksize <= 0: - raise SyntaxError("invalid block header") + msg = "invalid block header" + raise SyntaxError(msg) i += HEADERSIZE blocksize -= HEADERSIZE - dct[sig] = (i, blocksize) + self.dct[sig] = (i, blocksize) fobj.seek(blocksize, io.SEEK_CUR) i += blocksize - def itersizes(self): + def itersizes(self) -> list[tuple[int, int, int]]: sizes = [] for size, fmts in self.SIZES.items(): - for (fmt, reader) in fmts: + for fmt, reader in fmts: if fmt in self.dct: sizes.append(size) break return sizes - def bestsize(self): + def bestsize(self) -> tuple[int, int, int]: sizes = self.itersizes() if not sizes: - raise SyntaxError("No 32bit icon resources found") + msg = "No 32bit icon resources found" + raise SyntaxError(msg) return max(sizes) - def dataforsize(self, size): + def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]: """ Get an icon resource as {channel: array}. Note that the arrays are bottom-up like windows bitmaps and will likely @@ -208,18 +227,20 @@ def dataforsize(self, size): dct.update(reader(self.fobj, desc, size)) return dct - def getimage(self, size=None): + def getimage( + self, size: tuple[int, int] | tuple[int, int, int] | None = None + ) -> Image.Image: if size is None: size = self.bestsize() - if len(size) == 2: + elif len(size) == 2: size = (size[0], size[1], 1) channels = self.dataforsize(size) - im = channels.get("RGBA", None) + im = channels.get("RGBA") if im: return im - im = channels.get("RGB").copy() + im = channels["RGB"].copy() try: im.putalpha(channels["A"]) except KeyError: @@ -245,9 +266,9 @@ class IcnsImageFile(ImageFile.ImageFile): format = "ICNS" format_description = "Mac OS icns resource" - def _open(self): + def _open(self) -> None: self.icns = IcnsFile(self.fp) - self.mode = "RGBA" + self._mode = "RGBA" self.info["sizes"] = self.icns.itersizes() self.best_size = self.icns.bestsize() self.size = ( @@ -255,55 +276,57 @@ def _open(self): self.best_size[1] * self.best_size[2], ) - @property - def size(self): + @property # type: ignore[override] + def size(self) -> tuple[int, int] | tuple[int, int, int]: return self._size @size.setter - def size(self, value): - info_size = value - if info_size not in self.info["sizes"] and len(info_size) == 2: - info_size = (info_size[0], info_size[1], 1) - if ( - info_size not in self.info["sizes"] - and len(info_size) == 3 - and info_size[2] == 1 - ): - simple_sizes = [ - (size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"] - ] - if value in simple_sizes: - info_size = self.info["sizes"][simple_sizes.index(value)] - if info_size not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") - self._size = value - - def load(self): - if len(self.size) == 3: - self.best_size = self.size - self.size = ( - self.best_size[0] * self.best_size[2], - self.best_size[1] * self.best_size[2], - ) - - Image.Image.load(self) - if self.im and self.im.size == self.size: + def size(self, value: tuple[int, int] | tuple[int, int, int]) -> None: + if len(value) == 3: + deprecate("Setting size to (width, height, scale)", 12, "load(scale)") + if value in self.info["sizes"]: + self._size = value # type: ignore[assignment] + return + else: + # Check that a matching size exists, + # or that there is a scale that would create a size that matches + for size in self.info["sizes"]: + simple_size = size[0] * size[2], size[1] * size[2] + scale = simple_size[0] // value[0] + if simple_size[1] / value[1] == scale: + self._size = value + return + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) + + def load(self, scale: int | None = None) -> Image.core.PixelAccess | None: + if scale is not None or len(self.size) == 3: + if scale is None and len(self.size) == 3: + scale = self.size[2] + assert scale is not None + width, height = self.size[:2] + self.size = width * scale, height * scale + self.best_size = width, height, scale + + px = Image.Image.load(self) + if self._im is not None and self.im.size == self.size: # Already loaded - return + return px self.load_prepare() # This is likely NOT the best way to do it, but whatever. im = self.icns.getimage(self.best_size) # If this is a PNG or JPEG 2000, it won't be loaded yet - im.load() + px = im.load() self.im = im.im - self.mode = im.mode + self._mode = im.mode self.size = im.size - self.load_end() + + return px -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: """ Saves the image as a series of PNG files, that are then combined into a .icns file. @@ -337,30 +360,33 @@ def _save(im, fp, filename): entries = [] for type, size in sizes.items(): stream = size_streams[size] - entries.append({"type": type, "size": len(stream), "stream": stream}) + entries.append((type, HEADERSIZE + len(stream), stream)) # Header fp.write(MAGIC) - fp.write(struct.pack(">i", sum(entry["size"] for entry in entries))) + file_length = HEADERSIZE # Header + file_length += HEADERSIZE + 8 * len(entries) # TOC + file_length += sum(entry[1] for entry in entries) + fp.write(struct.pack(">i", file_length)) # TOC fp.write(b"TOC ") fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE)) for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", HEADERSIZE + entry["size"])) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) # Data for entry in entries: - fp.write(entry["type"]) - fp.write(struct.pack(">i", HEADERSIZE + entry["size"])) - fp.write(entry["stream"]) + fp.write(entry[0]) + fp.write(struct.pack(">i", entry[1])) + fp.write(entry[2]) if hasattr(fp, "flush"): fp.flush() -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == MAGIC @@ -378,8 +404,8 @@ def _accept(prefix): with open(sys.argv[1], "rb") as fp: imf = IcnsImageFile(fp) for size in imf.info["sizes"]: - imf.size = size - imf.save("out-%s-%s-%s.png" % size) + width, height, scale = imf.size = size + imf.save(f"out-{width}-{height}-{scale}.png") with Image.open(sys.argv[1]) as im: im.save("out.png") if sys.platform == "windows": diff --git a/dependencies/WIN/PIL/IcoImagePlugin.py b/dependencies/WIN/PIL/IcoImagePlugin.py index d9ff9b5..e879f18 100644 --- a/dependencies/WIN/PIL/IcoImagePlugin.py +++ b/dependencies/WIN/PIL/IcoImagePlugin.py @@ -20,16 +20,18 @@ # Icon format references: # * https://en.wikipedia.org/wiki/ICO_(file_format) # * https://msdn.microsoft.com/en-us/library/ms997538.aspx +from __future__ import annotations - -import struct import warnings from io import BytesIO from math import ceil, log +from typing import IO, NamedTuple from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin from ._binary import i16le as i16 from ._binary import i32le as i32 +from ._binary import o8 +from ._binary import o16le as o16 from ._binary import o32le as o32 # @@ -38,59 +40,76 @@ _MAGIC = b"\0\0\1\0" -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: fp.write(_MAGIC) # (2+2) + bmp = im.encoderinfo.get("bitmap_format") == "bmp" sizes = im.encoderinfo.get( "sizes", [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], ) + frames = [] + provided_ims = [im] + im.encoderinfo.get("append_images", []) width, height = im.size - sizes = filter( - lambda x: False - if (x[0] > width or x[1] > height or x[0] > 256 or x[1] > 256) - else True, - sizes, - ) - sizes = list(sizes) - fp.write(struct.pack(" width or size[1] > height or size[0] > 256 or size[1] > 256: + continue + + for provided_im in provided_ims: + if provided_im.size != size: + continue + frames.append(provided_im) + if bmp: + bits = BmpImagePlugin.SAVE[provided_im.mode][1] + bits_used = [bits] + for other_im in provided_ims: + if other_im.size != size: + continue + bits = BmpImagePlugin.SAVE[other_im.mode][1] + if bits not in bits_used: + # Another image has been supplied for this size + # with a different bit depth + frames.append(other_im) + bits_used.append(bits) + break + else: + # TODO: invent a more convenient method for proportional scalings + frame = provided_im.copy() + frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None) + frames.append(frame) + fp.write(o16(len(frames))) # idCount(2) + offset = fp.tell() + len(frames) * 16 + for frame in frames: + width, height = frame.size # 0 means 256 - fp.write(struct.pack("B", width if width < 256 else 0)) # bWidth(1) - fp.write(struct.pack("B", height if height < 256 else 0)) # bHeight(1) - fp.write(b"\0") # bColorCount(1) + fp.write(o8(width if width < 256 else 0)) # bWidth(1) + fp.write(o8(height if height < 256 else 0)) # bHeight(1) + + bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0) + fp.write(o8(colors)) # bColorCount(1) fp.write(b"\0") # bReserved(1) fp.write(b"\0\0") # wPlanes(2) - - tmp = provided_images.get(size) - if not tmp: - # TODO: invent a more convenient method for proportional scalings - tmp = im.copy() - tmp.thumbnail(size, Image.LANCZOS, reducing_gap=None) - bits = BmpImagePlugin.SAVE[tmp.mode][1] if bmp else 32 - fp.write(struct.pack(" bool: return prefix[:4] == _MAGIC +class IconHeader(NamedTuple): + width: int + height: int + nb_color: int + reserved: int + planes: int + bpp: int + size: int + offset: int + dim: tuple[int, int] + square: int + color_depth: int + + class IcoFile: - def __init__(self, buf): + def __init__(self, buf: IO[bytes]) -> None: """ Parse image from file-like object containing ico file data """ @@ -111,7 +144,8 @@ def __init__(self, buf): # check magic s = buf.read(6) if not _accept(s): - raise SyntaxError("not an ICO file") + msg = "not an ICO file" + raise SyntaxError(msg) self.buf = buf self.entry = [] @@ -123,73 +157,65 @@ def __init__(self, buf): for i in range(self.nb_items): s = buf.read(16) - icon_header = { - "width": s[0], - "height": s[1], - "nb_color": s[2], # No. of colors in image (0 if >=8bpp) - "reserved": s[3], - "planes": i16(s, 4), - "bpp": i16(s, 6), - "size": i32(s, 8), - "offset": i32(s, 12), - } - # See Wikipedia - for j in ("width", "height"): - if not icon_header[j]: - icon_header[j] = 256 - - # See Wikipedia notes about color depth. - # We need this just to differ images with equal sizes - icon_header["color_depth"] = ( - icon_header["bpp"] - or ( - icon_header["nb_color"] != 0 - and ceil(log(icon_header["nb_color"], 2)) - ) - or 256 + width = s[0] or 256 + height = s[1] or 256 + + # No. of colors in image (0 if >=8bpp) + nb_color = s[2] + bpp = i16(s, 6) + icon_header = IconHeader( + width=width, + height=height, + nb_color=nb_color, + reserved=s[3], + planes=i16(s, 4), + bpp=i16(s, 6), + size=i32(s, 8), + offset=i32(s, 12), + dim=(width, height), + square=width * height, + # See Wikipedia notes about color depth. + # We need this just to differ images with equal sizes + color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256, ) - icon_header["dim"] = (icon_header["width"], icon_header["height"]) - icon_header["square"] = icon_header["width"] * icon_header["height"] - self.entry.append(icon_header) - self.entry = sorted(self.entry, key=lambda x: x["color_depth"]) + self.entry = sorted(self.entry, key=lambda x: x.color_depth) # ICO images are usually squares - # self.entry = sorted(self.entry, key=lambda x: x['width']) - self.entry = sorted(self.entry, key=lambda x: x["square"]) - self.entry.reverse() + self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True) - def sizes(self): + def sizes(self) -> set[tuple[int, int]]: """ - Get a list of all available icon sizes and color depths. + Get a set of all available icon sizes and color depths. """ - return {(h["width"], h["height"]) for h in self.entry} + return {(h.width, h.height) for h in self.entry} - def getentryindex(self, size, bpp=False): - for (i, h) in enumerate(self.entry): - if size == h["dim"] and (bpp is False or bpp == h["color_depth"]): + def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int: + for i, h in enumerate(self.entry): + if size == h.dim and (bpp is False or bpp == h.color_depth): return i return 0 - def getimage(self, size, bpp=False): + def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image: """ Get an image from the icon """ return self.frame(self.getentryindex(size, bpp)) - def frame(self, idx): + def frame(self, idx: int) -> Image.Image: """ Get an image from frame idx """ header = self.entry[idx] - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) data = self.buf.read(8) - self.buf.seek(header["offset"]) + self.buf.seek(header.offset) + im: Image.Image if data[:8] == PngImagePlugin._MAGIC: # png frame im = PngImagePlugin.PngImageFile(self.buf) @@ -202,11 +228,10 @@ def frame(self, idx): # change tile dimension to only encompass XOR image im._size = (im.size[0], int(im.size[1] / 2)) d, e, o, a = im.tile[0] - im.tile[0] = d, (0, 0) + im.size, o, a + im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a) # figure out where AND mask image starts - bpp = header["bpp"] - if 32 == bpp: + if header.bpp == 32: # 32-bit color depth icon image allows semitransparent areas # PIL's DIB format ignores transparency bits, recover them. # The DIB is packed in BGRX byte order where X is the alpha @@ -218,13 +243,19 @@ def frame(self, idx): alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] # convert to an 8bpp grayscale image - mask = Image.frombuffer( - "L", # 8bpp - im.size, # (w, h) - alpha_bytes, # source chars - "raw", # raw decoder - ("L", 0, -1), # 8bpp inverted, unpadded, reversed - ) + try: + mask = Image.frombuffer( + "L", # 8bpp + im.size, # (w, h) + alpha_bytes, # source chars + "raw", # raw decoder + ("L", 0, -1), # 8bpp inverted, unpadded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise else: # get AND image from end of bitmap w = im.size[0] @@ -236,25 +267,32 @@ def frame(self, idx): # padded row size * height / bits per char total_bytes = int((w * im.size[1]) / 8) - and_mask_offset = header["offset"] + header["size"] - total_bytes + and_mask_offset = header.offset + header.size - total_bytes self.buf.seek(and_mask_offset) mask_data = self.buf.read(total_bytes) # convert raw data to image - mask = Image.frombuffer( - "1", # 1 bpp - im.size, # (w, h) - mask_data, # source chars - "raw", # raw decoder - ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed - ) + try: + mask = Image.frombuffer( + "1", # 1 bpp + im.size, # (w, h) + mask_data, # source chars + "raw", # raw decoder + ("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed + ) + except ValueError: + if ImageFile.LOAD_TRUNCATED_IMAGES: + mask = None + else: + raise # now we have two images, im is XOR image and mask is AND image # apply mask image as alpha channel - im = im.convert("RGBA") - im.putalpha(mask) + if mask: + im = im.convert("RGBA") + im.putalpha(mask) return im @@ -287,31 +325,34 @@ class IcoImageFile(ImageFile.ImageFile): format = "ICO" format_description = "Windows Icon" - def _open(self): + def _open(self) -> None: self.ico = IcoFile(self.fp) self.info["sizes"] = self.ico.sizes() - self.size = self.ico.entry[0]["dim"] + self.size = self.ico.entry[0].dim self.load() @property - def size(self): + def size(self) -> tuple[int, int]: return self._size @size.setter - def size(self, value): + def size(self, value: tuple[int, int]) -> None: if value not in self.info["sizes"]: - raise ValueError("This is not one of the allowed sizes of this image") + msg = "This is not one of the allowed sizes of this image" + raise ValueError(msg) self._size = value - def load(self): - if self.im and self.im.size == self.size: + def load(self) -> Image.core.PixelAccess | None: + if self._im is not None and self.im.size == self.size: # Already loaded - return + return Image.Image.load(self) im = self.ico.getimage(self.size) # if tile is PNG, it won't really be loaded yet im.load() self.im = im.im - self.mode = im.mode + self._mode = im.mode + if im.palette: + self.palette = im.palette if im.size != self.size: warnings.warn("Image was not the expected size") @@ -321,8 +362,9 @@ def load(self): self.info["sizes"] = set(sizes) self.size = im.size + return None - def load_seek(self): + def load_seek(self, pos: int) -> None: # Flag the ImageFile.Parser so that it # just does all the decode at the end. pass diff --git a/dependencies/WIN/PIL/ImImagePlugin.py b/dependencies/WIN/PIL/ImImagePlugin.py index 1dfc808..b4215a0 100644 --- a/dependencies/WIN/PIL/ImImagePlugin.py +++ b/dependencies/WIN/PIL/ImImagePlugin.py @@ -24,10 +24,11 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import re +from typing import IO, Any from . import Image, ImageFile, ImagePalette @@ -78,7 +79,7 @@ "LA image": ("LA", "LA;L"), "PA image": ("LA", "PA;L"), "RGBA image": ("RGBA", "RGBA;L"), - "RGBX image": ("RGBX", "RGBX;L"), + "RGBX image": ("RGB", "RGBX;L"), "CMYK image": ("CMYK", "CMYK;L"), "YCC image": ("YCbCr", "YCbCr;L"), } @@ -93,17 +94,17 @@ for i in ["32S"]: OPEN[f"L {i} image"] = ("I", f"I;{i}") OPEN[f"L*{i} image"] = ("I", f"I;{i}") -for i in range(2, 33): - OPEN[f"L*{i} image"] = ("F", f"F;{i}") +for j in range(2, 33): + OPEN[f"L*{j} image"] = ("F", f"F;{j}") # -------------------------------------------------------------------- # Read IM directory -split = re.compile(br"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") +split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$") -def number(s): +def number(s: Any) -> float: try: return int(s) except ValueError: @@ -115,18 +116,17 @@ def number(s): class ImImageFile(ImageFile.ImageFile): - format = "IM" format_description = "IFUNC Image Memory" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # Quick rejection: if there's not an LF among the first # 100 bytes, this is (probably) not a text header. if b"\n" not in self.fp.read(100): - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) self.fp.seek(0) n = 0 @@ -139,7 +139,6 @@ def _open(self): self.rawmode = "L" while True: - s = self.fp.read(1) # Some versions of IFUNC uses \n\r instead of \r\n... @@ -153,7 +152,8 @@ def _open(self): s = s + self.fp.readline() if len(s) > 100: - raise SyntaxError("not an IM file") + msg = "not an IM file" + raise SyntaxError(msg) if s[-2:] == b"\r\n": s = s[:-2] @@ -163,10 +163,10 @@ def _open(self): try: m = split.match(s) except re.error as e: - raise SyntaxError("not an IM file") from e + msg = "not an IM file" + raise SyntaxError(msg) from e if m: - k, v = m.group(1, 2) # Don't know if this is the correct encoding, @@ -197,23 +197,23 @@ def _open(self): n += 1 else: - - raise SyntaxError( - "Syntax error in IM header: " + s.decode("ascii", "replace") - ) + msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}" + raise SyntaxError(msg) if not n: - raise SyntaxError("Not an IM file") + msg = "Not an IM file" + raise SyntaxError(msg) # Basic attributes self._size = self.info[SIZE] - self.mode = self.info[MODE] + self._mode = self.info[MODE] # Skip forward to start of image data - while s and s[0:1] != b"\x1A": + while s and s[:1] != b"\x1A": s = self.fp.read(1) if not s: - raise SyntaxError("File truncated") + msg = "File truncated" + raise SyntaxError(msg) if LUT in self.info: # convert lookup table to palette or lut attribute @@ -232,9 +232,9 @@ def _open(self): self.lut = list(palette[:256]) else: if self.mode in ["L", "P"]: - self.mode = self.rawmode = "P" + self._mode = self.rawmode = "P" elif self.mode in ["LA", "PA"]: - self.mode = "PA" + self._mode = "PA" self.rawmode = "PA;L" self.palette = ImagePalette.raw("RGB;L", palette) elif self.mode == "RGB": @@ -245,16 +245,19 @@ def _open(self): self.__offset = offs = self.fp.tell() - self.__fp = self.fp # FIXME: hack + self._fp = self.fp # FIXME: hack if self.rawmode[:2] == "F;": - # ifunc95 formats try: # use bit decoder (if necessary) bits = int(self.rawmode[2:]) if bits not in [8, 16, 32]: - self.tile = [("bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1))] + self.tile = [ + ImageFile._Tile( + "bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1) + ) + ] return except ValueError: pass @@ -264,23 +267,27 @@ def _open(self): # ever stumbled upon such a file ;-) size = self.size[0] * self.size[1] self.tile = [ - ("raw", (0, 0) + self.size, offs, ("G", 0, -1)), - ("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), - ("raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)), + ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)), + ImageFile._Tile( + "raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1) + ), ] else: # LabEye/IFUNC files - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] @property - def n_frames(self): + def n_frames(self) -> int: return self.info[FRAMES] @property - def is_animated(self): + def is_animated(self) -> bool: return self.info[FRAMES] > 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return @@ -294,22 +301,15 @@ def seek(self, frame): size = ((self.size[0] * bits + 7) // 8) * self.size[1] offs = self.__offset + frame * size - self.fp = self.__fp + self.fp = self._fp - self.tile = [("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1)) + ] - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # -------------------------------------------------------------------- @@ -336,12 +336,12 @@ def _close__fp(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: image_type, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as IM") from e + msg = f"Cannot save {im.mode} images as IM" + raise ValueError(msg) from e frames = im.encoderinfo.get("frames", 1) @@ -351,18 +351,28 @@ def _save(im, fp, filename): # or: SyntaxError("not an IM file") # 8 characters are used for "Name: " and "\r\n" # Keep just the filename, ditch the potentially overlong path + if isinstance(filename, bytes): + filename = filename.decode("ascii") name, ext = os.path.splitext(os.path.basename(filename)) name = "".join([name[: 92 - len(ext)], ext]) fp.write(f"Name: {name}\r\n".encode("ascii")) - fp.write(("Image size (x*y): %d*%d\r\n" % im.size).encode("ascii")) + fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii")) fp.write(f"File size (no of images): {frames}\r\n".encode("ascii")) if im.mode in ["P", "PA"]: fp.write(b"Lut: 1\r\n") fp.write(b"\000" * (511 - fp.tell()) + b"\032") if im.mode in ["P", "PA"]: - fp.write(im.im.getpalette("RGB", "RGB;L")) # 768 bytes - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]) + im_palette = im.im.getpalette("RGB", "RGB;L") + colors = len(im_palette) // 3 + palette = b"" + for i in range(3): + palette += im_palette[colors * i : colors * (i + 1)] + palette += b"\x00" * (256 - colors) + fp.write(palette) # 768 bytes + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))] + ) # diff --git a/dependencies/WIN/PIL/Image.py b/dependencies/WIN/PIL/Image.py index 7dd5b35..dff3d06 100644 --- a/dependencies/WIN/PIL/Image.py +++ b/dependencies/WIN/PIL/Image.py @@ -24,71 +24,52 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import atexit import builtins import io import logging import math -import numbers import os import re import struct import sys import tempfile import warnings -from collections.abc import Callable, MutableMapping -from pathlib import Path - -try: - import defusedxml.ElementTree as ElementTree -except ImportError: - ElementTree = None +from collections.abc import Callable, Iterator, MutableMapping, Sequence +from enum import IntEnum +from types import ModuleType +from typing import ( + IO, + TYPE_CHECKING, + Any, + Literal, + Protocol, + cast, +) # VERSION was removed in Pillow 6.0.0. -# PILLOW_VERSION is deprecated and will be removed in a future release. +# PILLOW_VERSION was removed in Pillow 9.0.0. # Use __version__ instead. from . import ( + ExifTags, ImageMode, TiffTags, UnidentifiedImageError, __version__, _plugins, - _raise_version_warning, ) -from ._binary import i32le -from ._util import deferred_error, isPath - -if sys.version_info >= (3, 7): - - def __getattr__(name): - if name == "PILLOW_VERSION": - _raise_version_warning() - return __version__ - else: - categories = {"NORMAL": 0, "SEQUENCE": 1, "CONTAINER": 2} - if name in categories: - warnings.warn( - "Image categories are deprecated and will be removed in Pillow 10 " - "(2023-01-02). Use is_animated instead.", - DeprecationWarning, - stacklevel=2, - ) - return categories[name] - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -else: - - from . import PILLOW_VERSION - - # Silence warning - assert PILLOW_VERSION - - # categories - NORMAL = 0 - SEQUENCE = 1 - CONTAINER = 2 +from ._binary import i32le, o32be, o32le +from ._deprecate import deprecate +from ._util import DeferredError, is_path +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None logger = logging.getLogger(__name__) @@ -101,8 +82,10 @@ class DecompressionBombError(Exception): pass +WARN_POSSIBLE_FORMATS: bool = False + # Limit to around a quarter gigabyte for a 24-bit (3 bpp) image -MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) +MAX_IMAGE_PIXELS: int | None = int(1024 * 1024 * 1024 // 4 // 3) try: @@ -114,14 +97,15 @@ class DecompressionBombError(Exception): from . import _imaging as core if __version__ != getattr(core, "PILLOW_VERSION", None): - raise ImportError( + msg = ( "The _imaging extension was built for another version of Pillow or PIL:\n" f"Core version: {getattr(core, 'PILLOW_VERSION', None)}\n" f"Pillow version: {__version__}" ) + raise ImportError(msg) except ImportError as v: - core = deferred_error(ImportError("The _imaging C module is not installed.")) + core = DeferredError.new(ImportError("The _imaging C module is not installed.")) # Explanations for ways that we know we might have an import error if str(v).startswith("Module use of python"): # The _imaging C module is present, but not compiled for @@ -138,15 +122,7 @@ class DecompressionBombError(Exception): raise -# works everywhere, win for pypy, not cpython -USE_CFFI_ACCESS = hasattr(sys, "pypy_version_info") -try: - import cffi -except ImportError: - cffi = None - - -def isImageType(t): +def isImageType(t: Any) -> TypeGuard[Image]: """ Checks if an object is an image object. @@ -157,55 +133,79 @@ def isImageType(t): :param t: object to check if it's an image :returns: True if the object is an image """ + deprecate("Image.isImageType(im)", 12, "isinstance(im, Image.Image)") return hasattr(t, "im") # # Constants -NONE = 0 # transpose -FLIP_LEFT_RIGHT = 0 -FLIP_TOP_BOTTOM = 1 -ROTATE_90 = 2 -ROTATE_180 = 3 -ROTATE_270 = 4 -TRANSPOSE = 5 -TRANSVERSE = 6 +class Transpose(IntEnum): + FLIP_LEFT_RIGHT = 0 + FLIP_TOP_BOTTOM = 1 + ROTATE_90 = 2 + ROTATE_180 = 3 + ROTATE_270 = 4 + TRANSPOSE = 5 + TRANSVERSE = 6 + # transforms (also defined in Imaging.h) -AFFINE = 0 -EXTENT = 1 -PERSPECTIVE = 2 -QUAD = 3 -MESH = 4 +class Transform(IntEnum): + AFFINE = 0 + EXTENT = 1 + PERSPECTIVE = 2 + QUAD = 3 + MESH = 4 -# resampling filters (also defined in Imaging.h) -NEAREST = NONE = 0 -BOX = 4 -BILINEAR = LINEAR = 2 -HAMMING = 5 -BICUBIC = CUBIC = 3 -LANCZOS = ANTIALIAS = 1 -_filters_support = {BOX: 0.5, BILINEAR: 1.0, HAMMING: 1.0, BICUBIC: 2.0, LANCZOS: 3.0} +# resampling filters (also defined in Imaging.h) +class Resampling(IntEnum): + NEAREST = 0 + BOX = 4 + BILINEAR = 2 + HAMMING = 5 + BICUBIC = 3 + LANCZOS = 1 + + +_filters_support = { + Resampling.BOX: 0.5, + Resampling.BILINEAR: 1.0, + Resampling.HAMMING: 1.0, + Resampling.BICUBIC: 2.0, + Resampling.LANCZOS: 3.0, +} # dithers -NEAREST = NONE = 0 -ORDERED = 1 # Not yet implemented -RASTERIZE = 2 # Not yet implemented -FLOYDSTEINBERG = 3 # default +class Dither(IntEnum): + NONE = 0 + ORDERED = 1 # Not yet implemented + RASTERIZE = 2 # Not yet implemented + FLOYDSTEINBERG = 3 # default + # palettes/quantizers -WEB = 0 -ADAPTIVE = 1 +class Palette(IntEnum): + WEB = 0 + ADAPTIVE = 1 + + +class Quantize(IntEnum): + MEDIANCUT = 0 + MAXCOVERAGE = 1 + FASTOCTREE = 2 + LIBIMAGEQUANT = 3 + + +module = sys.modules[__name__] +for enum in (Transpose, Transform, Resampling, Dither, Palette, Quantize): + for item in enum: + setattr(module, item.name, item.value) -MEDIANCUT = 0 -MAXCOVERAGE = 1 -FASTOCTREE = 2 -LIBIMAGEQUANT = 3 if hasattr(core, "DEFAULT_STRATEGY"): DEFAULT_STRATEGY = core.DEFAULT_STRATEGY @@ -218,70 +218,73 @@ def isImageType(t): # -------------------------------------------------------------------- # Registries -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -SAVE_ALL = {} -EXTENSION = {} -DECODERS = {} -ENCODERS = {} +if TYPE_CHECKING: + import mmap + from xml.etree.ElementTree import Element + + from IPython.lib.pretty import PrettyPrinter + + from . import ImageFile, ImageFilter, ImagePalette, ImageQt, TiffImagePlugin + from ._typing import CapsuleType, NumpyArray, StrOrBytesPath, TypeGuard +ID: list[str] = [] +OPEN: dict[ + str, + tuple[ + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + Callable[[bytes], bool | str] | None, + ], +] = {} +MIME: dict[str, str] = {} +SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +EXTENSION: dict[str, str] = {} +DECODERS: dict[str, type[ImageFile.PyDecoder]] = {} +ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {} # -------------------------------------------------------------------- # Modes -if sys.byteorder == "little": - _ENDIAN = "<" -else: - _ENDIAN = ">" - -_MODE_CONV = { - # official modes - "1": ("|b1", None), # Bits need to be extended to bytes - "L": ("|u1", None), - "LA": ("|u1", 2), - "I": (_ENDIAN + "i4", None), - "F": (_ENDIAN + "f4", None), - "P": ("|u1", None), - "RGB": ("|u1", 3), - "RGBX": ("|u1", 4), - "RGBA": ("|u1", 4), - "CMYK": ("|u1", 4), - "YCbCr": ("|u1", 3), - "LAB": ("|u1", 3), # UNDONE - unsigned |u1i1i1 - "HSV": ("|u1", 3), - # I;16 == I;16L, and I;32 == I;32L - "I;16": ("u2", None), - "I;16L": ("i2", None), - "I;16LS": ("u4", None), - "I;32L": ("i4", None), - "I;32LS": ("" + + +def _conv_type_shape(im: Image) -> tuple[tuple[int, ...], str]: + m = ImageMode.getmode(im.mode) + shape: tuple[int, ...] = (im.height, im.width) + extra = len(m.bands) + if extra != 1: + shape += (extra,) + return shape, m.typestr + + +MODES = [ + "1", + "CMYK", + "F", + "HSV", + "I", + "I;16", + "I;16B", + "I;16L", + "I;16N", + "L", + "LA", + "La", + "LAB", + "P", + "PA", + "RGB", + "RGBA", + "RGBa", + "RGBX", + "YCbCr", +] # raw modes that may be memory mapped. NOTE: if you change this, you # may have to modify the stride calculation in map.c too! _MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16L", "I;16B") -def getmodebase(mode): +def getmodebase(mode: str) -> str: """ Gets the "base" mode for given mode. This function returns "L" for images that contain grayscale data, and "RGB" for images that @@ -294,7 +297,7 @@ def getmodebase(mode): return ImageMode.getmode(mode).basemode -def getmodetype(mode): +def getmodetype(mode: str) -> str: """ Gets the storage type mode. Given a mode, this function returns a single-layer mode suitable for storing individual bands. @@ -306,7 +309,7 @@ def getmodetype(mode): return ImageMode.getmode(mode).basetype -def getmodebandnames(mode): +def getmodebandnames(mode: str) -> tuple[str, ...]: """ Gets a list of individual band names. Given a mode, this function returns a tuple containing the names of individual bands (use @@ -321,7 +324,7 @@ def getmodebandnames(mode): return ImageMode.getmode(mode).bands -def getmodebands(mode): +def getmodebands(mode: str) -> int: """ Gets the number of individual bands for this mode. @@ -338,8 +341,12 @@ def getmodebands(mode): _initialized = 0 -def preinit(): - """Explicitly load standard file format drivers.""" +def preinit() -> None: + """ + Explicitly loads BMP, GIF, JPEG, PPM and PPM file format drivers. + + It is called when opening or saving images. + """ global _initialized if _initialized >= 1: @@ -375,43 +382,44 @@ def preinit(): assert PngImagePlugin except ImportError: pass - # try: - # import TiffImagePlugin - # assert TiffImagePlugin - # except ImportError: - # pass _initialized = 1 -def init(): +def init() -> bool: """ Explicitly initializes the Python Imaging Library. This function loads all available file format drivers. + + It is called when opening or saving images if :py:meth:`~preinit()` is + insufficient, and by :py:meth:`~PIL.features.pilinfo`. """ global _initialized if _initialized >= 2: - return 0 + return False + parent_name = __name__.rpartition(".")[0] for plugin in _plugins: try: logger.debug("Importing %s", plugin) - __import__(f"PIL.{plugin}", globals(), locals(), []) + __import__(f"{parent_name}.{plugin}", globals(), locals(), []) except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) if OPEN or SAVE: _initialized = 2 - return 1 + return True + return False # -------------------------------------------------------------------- # Codec factories (used by tobytes/frombytes and ImageFile.load) -def _getdecoder(mode, decoder_name, args, extra=()): - +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: # tweak arguments if args is None: args = () @@ -427,14 +435,16 @@ def _getdecoder(mode, decoder_name, args, extra=()): try: # get decoder - decoder = getattr(core, decoder_name + "_decoder") + decoder = getattr(core, f"{decoder_name}_decoder") except AttributeError as e: - raise OSError(f"decoder {decoder_name} not available") from e + msg = f"decoder {decoder_name} not available" + raise OSError(msg) from e return decoder(mode, *args + extra) -def _getencoder(mode, encoder_name, args, extra=()): - +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: # tweak arguments if args is None: args = () @@ -450,9 +460,10 @@ def _getencoder(mode, encoder_name, args, extra=()): try: # get encoder - encoder = getattr(core, encoder_name + "_encoder") + encoder = getattr(core, f"{encoder_name}_encoder") except AttributeError as e: - raise OSError(f"encoder {encoder_name} not available") from e + msg = f"encoder {encoder_name} not available" + raise OSError(msg) from e return encoder(mode, *args + extra) @@ -460,51 +471,65 @@ def _getencoder(mode, encoder_name, args, extra=()): # Simple expression analyzer -def coerce_e(value): - return value if isinstance(value, _E) else _E(value) +class ImagePointTransform: + """ + Used with :py:meth:`~PIL.Image.Image.point` for single band images with more than + 8 bits, this represents an affine transformation, where the value is multiplied by + ``scale`` and ``offset`` is added. + """ + def __init__(self, scale: float, offset: float) -> None: + self.scale = scale + self.offset = offset -class _E: - def __init__(self, data): - self.data = data + def __neg__(self) -> ImagePointTransform: + return ImagePointTransform(-self.scale, -self.offset) - def __add__(self, other): - return _E((self.data, "__add__", coerce_e(other).data)) + def __add__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return ImagePointTransform( + self.scale + other.scale, self.offset + other.offset + ) + return ImagePointTransform(self.scale, self.offset + other) - def __mul__(self, other): - return _E((self.data, "__mul__", coerce_e(other).data)) + __radd__ = __add__ + def __sub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return self + -other -def _getscaleoffset(expr): - stub = ["stub"] - data = expr(_E(stub)).data - try: - (a, b, c) = data # simplified syntax - if a is stub and b == "__mul__" and isinstance(c, numbers.Number): - return c, 0.0 - if a is stub and b == "__add__" and isinstance(c, numbers.Number): - return 1.0, c - except TypeError: - pass - try: - ((a, b, c), d, e) = data # full syntax - if ( - a is stub - and b == "__mul__" - and isinstance(c, numbers.Number) - and d == "__add__" - and isinstance(e, numbers.Number) - ): - return c, e - except TypeError: - pass - raise ValueError("illegal expression") + def __rsub__(self, other: ImagePointTransform | float) -> ImagePointTransform: + return other + -self + + def __mul__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale * other, self.offset * other) + + __rmul__ = __mul__ + + def __truediv__(self, other: ImagePointTransform | float) -> ImagePointTransform: + if isinstance(other, ImagePointTransform): + return NotImplemented + return ImagePointTransform(self.scale / other, self.offset / other) + + +def _getscaleoffset( + expr: Callable[[ImagePointTransform], ImagePointTransform | float] +) -> tuple[float, float]: + a = expr(ImagePointTransform(1, 0)) + return (a.scale, a.offset) if isinstance(a, ImagePointTransform) else (0, a) # -------------------------------------------------------------------- # Implementation wrapper +class SupportsGetData(Protocol): + def getdata( + self, + ) -> tuple[Transform, Sequence[int]]: ... + + class Image: """ This class represents an image object. To create @@ -517,50 +542,52 @@ class Image: * :py:func:`~PIL.Image.frombytes` """ - format = None - format_description = None + format: str | None = None + format_description: str | None = None _close_exclusive_fp_after_loading = True - def __init__(self): + def __init__(self) -> None: # FIXME: take "new" parameters / other image? # FIXME: turn mode and size into delegating properties? - self.im = None - self.mode = "" + self._im: core.ImagingCore | DeferredError | None = None + self._mode = "" self._size = (0, 0) - self.palette = None - self.info = {} - self._category = 0 + self.palette: ImagePalette.ImagePalette | None = None + self.info: dict[str | tuple[int, int], Any] = {} self.readonly = 0 - self.pyaccess = None - self._exif = None - - def __getattr__(self, name): - if name == "category": - warnings.warn( - "Image categories are deprecated and will be removed in Pillow 10 " - "(2023-01-02). Use is_animated instead.", - DeprecationWarning, - stacklevel=2, - ) - return self._category - raise AttributeError(name) + self._exif: Exif | None = None + + @property + def im(self) -> core.ImagingCore: + if isinstance(self._im, DeferredError): + raise self._im.ex + assert self._im is not None + return self._im + + @im.setter + def im(self, im: core.ImagingCore) -> None: + self._im = im @property - def width(self): + def width(self) -> int: return self.size[0] @property - def height(self): + def height(self) -> int: return self.size[1] @property - def size(self): + def size(self) -> tuple[int, int]: return self._size - def _new(self, im): + @property + def mode(self) -> str: + return self._mode + + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im - new.mode = im.mode + new._mode = im.mode new._size = im.size if im.mode in ("P", "PA"): if self.palette: @@ -576,15 +603,21 @@ def _new(self, im): def __enter__(self): return self + def _close_fp(self): + if getattr(self, "_fp", False): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + def __exit__(self, *args): - if hasattr(self, "fp") and getattr(self, "_exclusive_fp", False): - if hasattr(self, "_close__fp"): - self._close__fp() - if self.fp: - self.fp.close() - self.fp = None + if hasattr(self, "fp"): + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None - def close(self): + def close(self) -> None: """ Closes the file pointer, if possible. @@ -596,39 +629,38 @@ def close(self): :py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for more information. """ - try: - if hasattr(self, "_close__fp"): - self._close__fp() - if self.fp: - self.fp.close() - self.fp = None - except Exception as msg: - logger.debug("Error closing: %s", msg) + if hasattr(self, "fp"): + try: + self._close_fp() + self.fp = None + except Exception as msg: + logger.debug("Error closing: %s", msg) if getattr(self, "map", None): - self.map = None + self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a # deferred error that will better explain that the core image # object is gone. - self.im = deferred_error(ValueError("Operation on closed image")) + self._im = DeferredError(ValueError("Operation on closed image")) - def _copy(self): + def _copy(self) -> None: self.load() self.im = self.im.copy() - self.pyaccess = None self.readonly = 0 - def _ensure_mutable(self): + def _ensure_mutable(self) -> None: if self.readonly: self._copy() else: self.load() - def _dump(self, file=None, format=None, **options): + def _dump( + self, file: str | None = None, format: str | None = None, **options: Any + ) -> str: suffix = "" if format: - suffix = "." + format + suffix = f".{format}" if not file: f, filename = tempfile.mkstemp(suffix) @@ -647,77 +679,91 @@ def _dump(self, file=None, format=None, **options): return filename - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) return ( - self.__class__ is other.__class__ - and self.mode == other.mode + self.mode == other.mode and self.size == other.size and self.info == other.info - and self._category == other._category - and self.readonly == other.readonly and self.getpalette() == other.getpalette() and self.tobytes() == other.tobytes() ) - def __repr__(self): - return "<%s.%s image mode=%s size=%dx%d at 0x%X>" % ( - self.__class__.__module__, - self.__class__.__name__, - self.mode, - self.size[0], - self.size[1], - id(self), + def __repr__(self) -> str: + return ( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]} " + f"at 0x{id(self):X}>" + ) + + def _repr_pretty_(self, p: PrettyPrinter, cycle: bool) -> None: + """IPython plain text display support""" + + # Same as __repr__ but without unpredictable id(self), + # to keep Jupyter notebook `text/plain` output stable. + p.text( + f"<{self.__class__.__module__}.{self.__class__.__name__} " + f"image mode={self.mode} size={self.size[0]}x{self.size[1]}>" ) - def _repr_png_(self): - """iPython display hook support + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: + """Helper function for iPython display hook. - :returns: png version of the image as bytes + :param image_format: Image format. + :returns: image as bytes, saved into the given format. """ b = io.BytesIO() try: - self.save(b, "PNG") - except Exception as e: - raise ValueError("Could not save to PNG for display") from e + self.save(b, image_format, **kwargs) + except Exception: + return None return b.getvalue() - def __array__(self, dtype=None): - # numpy array interface support - import numpy as np + def _repr_png_(self) -> bytes | None: + """iPython display hook support for PNG format. + + :returns: PNG version of the image as bytes + """ + return self._repr_image("PNG", compress_level=1) + + def _repr_jpeg_(self) -> bytes | None: + """iPython display hook support for JPEG format. + + :returns: JPEG version of the image as bytes + """ + return self._repr_image("JPEG") - new = {} - shape, typestr = _conv_type_shape(self) - new["shape"] = shape - new["typestr"] = typestr - new["version"] = 3 + @property + def __array_interface__(self) -> dict[str, str | bytes | int | tuple[int, ...]]: + # numpy array interface support + new: dict[str, str | bytes | int | tuple[int, ...]] = {"version": 3} if self.mode == "1": # Binary images need to be extended from bits to bytes # See: https://github.com/python-pillow/Pillow/issues/350 new["data"] = self.tobytes("raw", "L") else: new["data"] = self.tobytes() + new["shape"], new["typestr"] = _conv_type_shape(self) + return new - class ArrayData: - __array_interface__ = new - - return np.array(ArrayData(), dtype) - - def __getstate__(self): - return [self.info, self.mode, self.size, self.getpalette(), self.tobytes()] + def __getstate__(self) -> list[Any]: + im_data = self.tobytes() # load image first + return [self.info, self.mode, self.size, self.getpalette(), im_data] - def __setstate__(self, state): + def __setstate__(self, state: list[Any]) -> None: Image.__init__(self) - self.tile = [] - info, mode, size, palette, data = state + info, mode, size, palette, data = state[:5] self.info = info - self.mode = mode + self._mode = mode self._size = size self.im = core.new(mode, size) if mode in ("L", "LA", "P", "PA") and palette: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name="raw", *args): + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: """ Return image as a bytes object. @@ -730,37 +776,47 @@ def tobytes(self, encoder_name="raw", *args): :param encoder_name: What encoder to use. The default is to use the standard "raw" encoder. + + A list of C encoders can be seen under + codecs section of the function array in + :file:`_imaging.c`. Python encoders are + registered within the relevant plugins. :param args: Extra arguments to the encoder. :returns: A :py:class:`bytes` object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] - if encoder_name == "raw" and args == (): - args = self.mode + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode self.load() + if self.width == 0 or self.height == 0: + return b"" + # unpack data - e = _getencoder(self.mode, encoder_name, args) + e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) bufsize = max(65536, self.size[0] * 4) # see RawEncode.c - data = [] + output = [] while True: - l, s, d = e.encode(bufsize) - data.append(d) - if s: + bytes_consumed, errcode, data = e.encode(bufsize) + output.append(data) + if errcode: break - if s < 0: - raise RuntimeError(f"encoder error {s} in tobytes") + if errcode < 0: + msg = f"encoder error {errcode} in tobytes" + raise RuntimeError(msg) - return b"".join(data) + return b"".join(output) - def tobitmap(self, name="image"): + def tobitmap(self, name: str = "image") -> bytes: """ Returns the image converted to an X11 bitmap. @@ -773,7 +829,8 @@ def tobitmap(self, name="image"): self.load() if self.mode != "1": - raise ValueError("not a bitmap") + msg = "not a bitmap" + raise ValueError(msg) data = self.tobytes("xbm") return b"".join( [ @@ -785,7 +842,12 @@ def tobitmap(self, name="image"): ] ) - def frombytes(self, data, decoder_name="raw", *args): + def frombytes( + self, + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, + ) -> None: """ Loads this image with pixel data from a bytes object. @@ -793,25 +855,31 @@ def frombytes(self, data, decoder_name="raw", *args): but loads data into this image instead of creating a new image object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + if self.width == 0 or self.height == 0: + return + + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] # default format - if decoder_name == "raw" and args == (): - args = self.mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode # unpack data - d = _getdecoder(self.mode, decoder_name, args) + d = _getdecoder(self.mode, decoder_name, decoder_args) d.setimage(self.im) s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) - def load(self): + def load(self) -> core.PixelAccess | None: """ Allocates storage for the image and loads the pixel data. In normal cases, you don't need to call this method, since the @@ -824,42 +892,30 @@ def load(self): operations. See :ref:`file-handling` for more information. :returns: An image access object. - :rtype: :ref:`PixelAccess` or :py:class:`PIL.PyAccess` + :rtype: :py:class:`.PixelAccess` """ - if self.im and self.palette and self.palette.dirty: + if self._im is not None and self.palette and self.palette.dirty: # realize palette mode, arr = self.palette.getdata() - if mode == "RGBA": - mode = "RGB" - self.info["transparency"] = arr[3::4] - arr = bytes( - value for (index, value) in enumerate(arr) if index % 4 != 3 - ) - palette_length = self.im.putpalette(mode, arr) + self.im.putpalette(self.palette.mode, mode, arr) self.palette.dirty = 0 self.palette.rawmode = None - if "transparency" in self.info and mode in ("RGBA", "LA", "PA"): + if "transparency" in self.info and mode in ("LA", "PA"): if isinstance(self.info["transparency"], int): self.im.putpalettealpha(self.info["transparency"], 0) else: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" else: - self.palette.mode = "RGB" - self.palette.palette = self.im.getpalette()[: palette_length * 3] - - if self.im: - if cffi and USE_CFFI_ACCESS: - if self.pyaccess: - return self.pyaccess - from . import PyAccess - - self.pyaccess = PyAccess.new(self, self.readonly) - if self.pyaccess: - return self.pyaccess + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) + + if self._im is not None: return self.im.pixel_access(self.readonly) + return None - def verify(self): + def verify(self) -> None: """ Verifies the contents of a file. For data read from a file, this method attempts to determine if the file is broken, without @@ -870,26 +926,32 @@ def verify(self): """ pass - def convert(self, mode=None, matrix=None, dither=None, palette=WEB, colors=256): + def convert( + self, + mode: str | None = None, + matrix: tuple[float, ...] | None = None, + dither: Dither | None = None, + palette: Palette = Palette.WEB, + colors: int = 256, + ) -> Image: """ Returns a converted copy of this image. For the "P" mode, this method translates pixels through the palette. If mode is omitted, a mode is chosen so that all information in the image and the palette can be represented without a palette. - The current version supports all possible conversions between - "L", "RGB" and "CMYK." The ``matrix`` argument only supports "L" - and "RGB". + This supports all possible conversions between "L", "RGB" and "CMYK". The + ``matrix`` argument only supports "L" and "RGB". - When translating a color image to greyscale (mode "L"), + When translating a color image to grayscale (mode "L"), the library uses the ITU-R 601-2 luma transform:: L = R * 299/1000 + G * 587/1000 + B * 114/1000 - The default method of converting a greyscale ("L") or "RGB" + The default method of converting a grayscale ("L") or "RGB" image into a bilevel (mode "1") image uses Floyd-Steinberg dither to approximate the original image luminosity levels. If - dither is :data:`NONE`, all values larger than 127 are set to 255 (white), + dither is ``None``, all values larger than 127 are set to 255 (white), all other values to 0 (black). To use other thresholds, use the :py:meth:`~PIL.Image.Image.point` method. @@ -897,24 +959,31 @@ def convert(self, mode=None, matrix=None, dither=None, palette=WEB, colors=256): this passes the operation to :py:meth:`~PIL.Image.Image.quantize`, and ``dither`` and ``palette`` are ignored. + When converting from "PA", if an "RGBA" palette is present, the alpha + channel from the image will be used instead of the values from the palette. + :param mode: The requested mode. See: :ref:`concept-modes`. :param matrix: An optional conversion matrix. If given, this should be 4- or 12-tuple containing floating point values. :param dither: Dithering method, used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". - Available methods are :data:`NONE` or :data:`FLOYDSTEINBERG` (default). - Note that this is not used when ``matrix`` is supplied. + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). Note that this is not used when ``matrix`` is supplied. :param palette: Palette to use when converting from mode "RGB" - to "P". Available palettes are :data:`WEB` or :data:`ADAPTIVE`. - :param colors: Number of colors to use for the :data:`ADAPTIVE` palette. - Defaults to 256. + to "P". Available palettes are :data:`Palette.WEB` or + :data:`Palette.ADAPTIVE`. + :param colors: Number of colors to use for the :data:`Palette.ADAPTIVE` + palette. Defaults to 256. :rtype: :py:class:`~PIL.Image.Image` :returns: An :py:class:`~PIL.Image.Image` object. """ + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + self.load() - has_transparency = self.info.get("transparency") is not None + has_transparency = "transparency" in self.info if not mode and self.mode == "P": # determine default mode if self.palette: @@ -929,29 +998,28 @@ def convert(self, mode=None, matrix=None, dither=None, palette=WEB, colors=256): if matrix: # matrix conversion if mode not in ("L", "RGB"): - raise ValueError("illegal conversion") + msg = "illegal conversion" + raise ValueError(msg) im = self.im.convert_matrix(mode, matrix) - new = self._new(im) + new_im = self._new(im) if has_transparency and self.im.bands == 3: - transparency = new.info["transparency"] + transparency = new_im.info["transparency"] - def convert_transparency(m, v): - v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 - return max(0, min(255, int(v))) + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) if mode == "L": transparency = convert_transparency(matrix, transparency) elif len(mode) == 3: transparency = tuple( - [ - convert_transparency( - matrix[i * 4 : i * 4 + 4], transparency - ) - for i in range(0, len(transparency)) - ] + convert_transparency(matrix[i * 4 : i * 4 + 4], transparency) + for i in range(0, len(transparency)) ) - new.info["transparency"] = transparency - return new + new_im.info["transparency"] = transparency + return new_im if mode == "P" and self.mode == "RGBA": return self.quantize(colors) @@ -960,7 +1028,9 @@ def convert_transparency(m, v): delete_trns = False # transparency handling if has_transparency: - if self.mode in ("1", "L", "I", "RGB") and mode == "RGBA": + if (self.mode in ("1", "L", "I", "I;16") and mode in ("LA", "RGBA")) or ( + self.mode == "RGB" and mode in ("La", "LA", "RGBa", "RGBA") + ): # Use transparent conversion to promote from transparent # color to an alpha channel. new_im = self._new( @@ -980,11 +1050,13 @@ def convert_transparency(m, v): else: # get the new transparency color. # use existing conversions - trns_im = Image()._new(core.new(self.mode, (1, 1))) + trns_im = new(self.mode, (1, 1)) if self.mode == "P": - trns_im.putpalette(self.palette) + assert self.palette is not None + trns_im.putpalette(self.palette, self.palette.mode) if isinstance(t, tuple): err = "Couldn't allocate a palette color for transparency" + assert trns_im.palette is not None try: t = trns_im.palette.getcolor(t, self) except ValueError as e: @@ -1016,54 +1088,85 @@ def convert_transparency(m, v): elif isinstance(t, int): self.im.putpalettealpha(t, 0) else: - raise ValueError("Transparency for P mode should be bytes or int") + msg = "Transparency for P mode should be bytes or int" + raise ValueError(msg) - if mode == "P" and palette == ADAPTIVE: + if mode == "P" and palette == Palette.ADAPTIVE: im = self.im.quantize(colors) - new = self._new(im) + new_im = self._new(im) from . import ImagePalette - new.palette = ImagePalette.ImagePalette("RGB", new.im.getpalette("RGB")) + new_im.palette = ImagePalette.ImagePalette( + "RGB", new_im.im.getpalette("RGB") + ) if delete_trns: # This could possibly happen if we requantize to fewer colors. # The transparency would be totally off in that case. - del new.info["transparency"] + del new_im.info["transparency"] if trns is not None: try: - new.info["transparency"] = new.palette.getcolor(trns, new) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), # trns was converted to RGB + new_im, + ) except Exception: # if we can't make a transparent color, don't leave the old # transparency hanging around to mess us up. - del new.info["transparency"] + del new_im.info["transparency"] warnings.warn("Couldn't allocate palette entry for transparency") - return new + return new_im + + if "LAB" in (self.mode, mode): + im = self + if mode == "LAB": + if im.mode not in ("RGB", "RGBA", "RGBX"): + im = im.convert("RGBA") + other_mode = im.mode + else: + other_mode = mode + if other_mode in ("RGB", "RGBA", "RGBX"): + from . import ImageCms + + srgb = ImageCms.createProfile("sRGB") + lab = ImageCms.createProfile("LAB") + profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab] + transform = ImageCms.buildTransform( + profiles[0], profiles[1], im.mode, mode + ) + return transform.apply(im) # colorspace conversion if dither is None: - dither = FLOYDSTEINBERG + dither = Dither.FLOYDSTEINBERG try: im = self.im.convert(mode, dither) except ValueError: try: # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) + modebase = getmodebase(self.mode) + if modebase == self.mode: + raise + im = self.im.convert(modebase) im = im.convert(mode, dither) except KeyError as e: - raise ValueError("illegal conversion") from e + msg = "illegal conversion" + raise ValueError(msg) from e new_im = self._new(im) - if mode == "P" and palette != ADAPTIVE: + if mode == "P" and palette != Palette.ADAPTIVE: from . import ImagePalette - new_im.palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) + new_im.palette = ImagePalette.ImagePalette("RGB", im.getpalette("RGB")) if delete_trns: # crash fail if we leave a bytes transparency in an rgb/l mode. del new_im.info["transparency"] if trns is not None: - if new_im.mode == "P": + if new_im.mode == "P" and new_im.palette: try: - new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(tuple[int, ...], trns), new_im # trns was converted to RGB + ) except ValueError as e: del new_im.info["transparency"] if str(e) != "cannot allocate more than 256 colors": @@ -1076,74 +1179,90 @@ def convert_transparency(m, v): new_im.info["transparency"] = trns return new_im - def quantize(self, colors=256, method=None, kmeans=0, palette=None, dither=1): + def quantize( + self, + colors: int = 256, + method: int | None = None, + kmeans: int = 0, + palette: Image | None = None, + dither: Dither = Dither.FLOYDSTEINBERG, + ) -> Image: """ Convert the image to 'P' mode with the specified number of colors. :param colors: The desired number of colors, <= 256 - :param method: :data:`MEDIANCUT` (median cut), - :data:`MAXCOVERAGE` (maximum coverage), - :data:`FASTOCTREE` (fast octree), - :data:`LIBIMAGEQUANT` (libimagequant; check support using - :py:func:`PIL.features.check_feature` - with ``feature="libimagequant"``). - - By default, :data:`MEDIANCUT` will be used. - - The exception to this is RGBA images. :data:`MEDIANCUT` and - :data:`MAXCOVERAGE` do not support RGBA images, so - :data:`FASTOCTREE` is used by default instead. - :param kmeans: Integer + :param method: :data:`Quantize.MEDIANCUT` (median cut), + :data:`Quantize.MAXCOVERAGE` (maximum coverage), + :data:`Quantize.FASTOCTREE` (fast octree), + :data:`Quantize.LIBIMAGEQUANT` (libimagequant; check support + using :py:func:`PIL.features.check_feature` with + ``feature="libimagequant"``). + + By default, :data:`Quantize.MEDIANCUT` will be used. + + The exception to this is RGBA images. :data:`Quantize.MEDIANCUT` + and :data:`Quantize.MAXCOVERAGE` do not support RGBA images, so + :data:`Quantize.FASTOCTREE` is used by default instead. + :param kmeans: Integer greater than or equal to zero. :param palette: Quantize to the palette of given :py:class:`PIL.Image.Image`. :param dither: Dithering method, used when converting from mode "RGB" to "P" or from "RGB" or "L" to "1". - Available methods are :data:`NONE` or :data:`FLOYDSTEINBERG` (default). - Default: 1 (legacy setting) + Available methods are :data:`Dither.NONE` or :data:`Dither.FLOYDSTEINBERG` + (default). :returns: A new image - """ self.load() if method is None: # defaults: - method = MEDIANCUT + method = Quantize.MEDIANCUT if self.mode == "RGBA": - method = FASTOCTREE + method = Quantize.FASTOCTREE - if self.mode == "RGBA" and method not in (FASTOCTREE, LIBIMAGEQUANT): + if self.mode == "RGBA" and method not in ( + Quantize.FASTOCTREE, + Quantize.LIBIMAGEQUANT, + ): # Caller specified an invalid mode. - raise ValueError( + msg = ( "Fast Octree (method == 2) and libimagequant (method == 3) " "are the only valid methods for quantizing RGBA images" ) + raise ValueError(msg) if palette: # use palette from reference image palette.load() if palette.mode != "P": - raise ValueError("bad mode for palette image") - if self.mode != "RGB" and self.mode != "L": - raise ValueError( - "only RGB or L mode images can be quantized to a palette" - ) + msg = "bad mode for palette image" + raise ValueError(msg) + if self.mode not in {"RGB", "L"}: + msg = "only RGB or L mode images can be quantized to a palette" + raise ValueError(msg) im = self.im.convert("P", dither, palette.im) new_im = self._new(im) + assert palette.palette is not None new_im.palette = palette.palette.copy() return new_im + if kmeans < 0: + msg = "kmeans must not be negative" + raise ValueError(msg) + im = self._new(self.im.quantize(colors, method, kmeans)) from . import ImagePalette mode = im.im.getpalettemode() - im.palette = ImagePalette.ImagePalette(mode, im.im.getpalette(mode, mode)) + palette_data = im.im.getpalette(mode, mode)[: colors * len(mode)] + im.palette = ImagePalette.ImagePalette(mode, palette_data) return im - def copy(self): + def copy(self) -> Image: """ Copies this image. Use this method if you wish to paste things into an image, but still retain the original. @@ -1156,7 +1275,7 @@ def copy(self): __copy__ = copy - def crop(self, box=None): + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1172,10 +1291,19 @@ def crop(self, box=None): if box is None: return self.copy() + if box[2] < box[0]: + msg = "Coordinate 'right' is less than 'left'" + raise ValueError(msg) + elif box[3] < box[1]: + msg = "Coordinate 'lower' is less than 'upper'" + raise ValueError(msg) + self.load() return self._new(self._crop(self.im, box)) - def _crop(self, im, box): + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: """ Returns a rectangular region from the core image object im. @@ -1195,12 +1323,14 @@ def _crop(self, im, box): return im.crop((x0, y0, x1, y1)) - def draft(self, mode, size): + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the image that as closely as possible matches the given mode and size. For example, you can use this method to convert a color - JPEG to greyscale while loading it. + JPEG to grayscale while loading it. If any changes are made, returns a tuple with the chosen ``mode`` and ``box`` with coordinates of the original image within the altered one. @@ -1213,17 +1343,18 @@ def draft(self, mode, size): currently implemented only for JPEG and MPO images. :param mode: The requested mode. - :param size: The requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). """ pass - def _expand(self, xmargin, ymargin=None): + def _expand(self, xmargin: int, ymargin: int | None = None) -> Image: if ymargin is None: ymargin = xmargin self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) + return self._new(self.im.expand(xmargin, ymargin)) - def filter(self, filter): + def filter(self, filter: ImageFilter.Filter | type[ImageFilter.Filter]) -> Image: """ Filters this image using the given filter. For a list of available filters, see the :py:mod:`~PIL.ImageFilter` module. @@ -1235,23 +1366,22 @@ def filter(self, filter): self.load() - if isinstance(filter, Callable): + if callable(filter): filter = filter() if not hasattr(filter, "filter"): - raise TypeError( - "filter argument should be ImageFilter.Filter instance or class" - ) + msg = "filter argument should be ImageFilter.Filter instance or class" + raise TypeError(msg) multiband = isinstance(filter, ImageFilter.MultibandFilter) if self.im.bands == 1 or multiband: return self._new(filter.filter(self.im)) - ims = [] - for c in range(self.im.bands): - ims.append(self._new(filter.filter(self.im.getband(c)))) + ims = [ + self._new(filter.filter(self.im.getband(c))) for c in range(self.im.bands) + ] return merge(self.mode, ims) - def getbands(self): + def getbands(self) -> tuple[str, ...]: """ Returns a tuple containing the name of each band in this image. For example, ``getbands`` on an RGB image returns ("R", "G", "B"). @@ -1261,11 +1391,15 @@ def getbands(self): """ return ImageMode.getmode(self.mode).bands - def getbbox(self): + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. + :param alpha_only: Optional flag, defaulting to ``True``. + If ``True`` and the image has an alpha channel, trim transparent pixels. + Otherwise, trim pixels when all channels are zero. + Keyword-only argument. :returns: The bounding box is returned as a 4-tuple defining the left, upper, right, and lower pixel coordinate. See :ref:`coordinate-system`. If the image is completely empty, this @@ -1274,9 +1408,11 @@ def getbbox(self): """ self.load() - return self.im.getbbox() + return self.im.getbbox(alpha_only) - def getcolors(self, maxcolors=256): + def getcolors( + self, maxcolors: int = 256 + ) -> list[tuple[int, tuple[int, ...]]] | list[tuple[int, float]] | None: """ Returns a list of colors used in this image. @@ -1293,16 +1429,13 @@ def getcolors(self, maxcolors=256): self.load() if self.mode in ("1", "L", "P"): h = self.im.histogram() - out = [] - for i in range(256): - if h[i]: - out.append((h[i], i)) + out: list[tuple[int, float]] = [(h[i], i) for i in range(256) if h[i]] if len(out) > maxcolors: return None return out return self.im.getcolors(maxcolors) - def getdata(self, band=None): + def getdata(self, band: int | None = None) -> core.ImagingCore: """ Returns the contents of this image as a sequence object containing pixel values. The sequence object is flattened, so @@ -1325,9 +1458,9 @@ def getdata(self, band=None): return self.im.getband(band) return self.im # could be abused - def getextrema(self): + def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: """ - Gets the the minimum and maximum pixel values for each band in + Gets the minimum and maximum pixel values for each band in the image. :returns: For a single-band image, a 2-tuple containing the @@ -1337,18 +1470,22 @@ def getextrema(self): self.load() if self.im.bands > 1: - extrema = [] - for i in range(self.im.bands): - extrema.append(self.im.getband(i).getextrema()) - return tuple(extrema) + return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def _getxmp(self, xmp_tags): - def get_name(tag): - return tag.split("}")[1] + def getxmp(self) -> dict[str, Any]: + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ - def get_value(element): - value = {get_name(k): v for k, v in element.attrib.items()} + def get_name(tag: str) -> str: + return re.sub("^{[^}]+}", "", tag) + + def get_value(element: Element) -> str | dict[str, Any] | None: + value: dict[str, Any] = {get_name(k): v for k, v in element.attrib.items()} children = list(element) if children: for child in children: @@ -1370,13 +1507,22 @@ def get_value(element): if ElementTree is None: warnings.warn("XMP data cannot be read without defusedxml dependency") return {} - else: - root = ElementTree.fromstring(xmp_tags) - return {get_name(root.tag): get_value(root)} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"].rstrip(b"\x00")) + return {get_name(root.tag): get_value(root)} + + def getexif(self) -> Exif: + """ + Gets EXIF data from the image. - def getexif(self): + :returns: an :py:class:`~PIL.Image.Exif` object. + """ if self._exif is None: self._exif = Exif() + elif self._exif._loaded: + return self._exif + self._exif._loaded = True exif_info = self.info.get("exif") if exif_info is None: @@ -1385,22 +1531,75 @@ def getexif(self): "".join(self.info["Raw profile type exif"].split("\n")[3:]) ) elif hasattr(self, "tag_v2"): + self._exif.bigtiff = self.tag_v2._bigtiff self._exif.endian = self.tag_v2._endian self._exif.load_from_fp(self.fp, self.tag_v2._offset) if exif_info is not None: self._exif.load(exif_info) # XMP tags - if 0x0112 not in self._exif: + if ExifTags.Base.Orientation not in self._exif: xmp_tags = self.info.get("XML:com.adobe.xmp") if xmp_tags: - match = re.search(r'tiff:Orientation="([0-9])"', xmp_tags) + match = re.search(r'tiff:Orientation(="|>)([0-9])', xmp_tags) if match: - self._exif[0x0112] = int(match[1]) + self._exif[ExifTags.Base.Orientation] = int(match[2]) return self._exif - def getim(self): + def _reload_exif(self) -> None: + if self._exif is None or not self._exif._loaded: + return + self._exif._loaded = False + self.getexif() + + def get_child_images(self) -> list[ImageFile.ImageFile]: + child_images = [] + exif = self.getexif() + ifds = [] + if ExifTags.Base.SubIFDs in exif: + subifd_offsets = exif[ExifTags.Base.SubIFDs] + if subifd_offsets: + if not isinstance(subifd_offsets, tuple): + subifd_offsets = (subifd_offsets,) + for subifd_offset in subifd_offsets: + ifds.append((exif._get_ifd_dict(subifd_offset), subifd_offset)) + ifd1 = exif.get_ifd(ExifTags.IFD.IFD1) + if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset): + assert exif._info is not None + ifds.append((ifd1, exif._info.next)) + + offset = None + for ifd, ifd_offset in ifds: + current_offset = self.fp.tell() + if offset is None: + offset = current_offset + + fp = self.fp + if ifd is not None: + thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset) + if thumbnail_offset is not None: + thumbnail_offset += getattr(self, "_exif_offset", 0) + self.fp.seek(thumbnail_offset) + data = self.fp.read(ifd.get(ExifTags.Base.JpegIFByteCount)) + fp = io.BytesIO(data) + + with open(fp) as im: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): + im._frame_pos = [ifd_offset] + im._seek(0) + im.load() + child_images.append(im) + + if offset is not None: + self.fp.seek(offset) + return child_images + + def getim(self) -> CapsuleType: """ Returns a capsule that points to the internal image memory. @@ -1410,21 +1609,77 @@ def getim(self): self.load() return self.im.ptr - def getpalette(self): + def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None: """ Returns the image palette as a list. + :param rawmode: The mode in which to return the palette. ``None`` will + return the palette in its current mode. + + .. versionadded:: 9.1.0 + :returns: A list of color values [r, g, b, ...], or None if the image has no palette. """ self.load() try: - return list(self.im.getpalette()) + mode = self.im.getpalettemode() except ValueError: return None # no palette + if rawmode is None: + rawmode = mode + return list(self.im.getpalette(mode, rawmode)) - def getpixel(self, xy): + @property + def has_transparency_data(self) -> bool: + """ + Determine if an image has transparency data, whether in the form of an + alpha channel, a palette with an alpha channel, or a "transparency" key + in the info dictionary. + + Note the image might still appear solid, if all of the values shown + within are opaque. + + :returns: A boolean. + """ + if ( + self.mode in ("LA", "La", "PA", "RGBA", "RGBa") + or "transparency" in self.info + ): + return True + if self.mode == "P": + assert self.palette is not None + return self.palette.mode.endswith("A") + return False + + def apply_transparency(self) -> None: + """ + If a P mode image has a "transparency" key in the info dictionary, + remove the key and instead apply the transparency to the palette. + Otherwise, the image is unchanged. + """ + if self.mode != "P" or "transparency" not in self.info: + return + + from . import ImagePalette + + palette = self.getpalette("RGBA") + assert palette is not None + transparency = self.info["transparency"] + if isinstance(transparency, bytes): + for i, alpha in enumerate(transparency): + palette[i * 4 + 3] = alpha + else: + palette[transparency * 4 + 3] = 0 + self.palette = ImagePalette.ImagePalette("RGBA", bytes(palette)) + self.palette.dirty = 1 + + del self.info["transparency"] + + def getpixel( + self, xy: tuple[int, int] | list[int] + ) -> float | tuple[int, ...] | None: """ Returns the pixel value at a given position. @@ -1435,11 +1690,9 @@ def getpixel(self, xy): """ self.load() - if self.pyaccess: - return self.pyaccess.getpixel(xy) - return self.im.getpixel(xy) + return self.im.getpixel(tuple(xy)) - def getprojection(self): + def getprojection(self) -> tuple[list[int], list[int]]: """ Get projection to x and y axes @@ -1451,21 +1704,24 @@ def getprojection(self): x, y = self.im.getprojection() return list(x), list(y) - def histogram(self, mask=None, extrema=None): + def histogram( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> list[int]: """ - Returns a histogram for the image. The histogram is returned as - a list of pixel counts, one for each pixel value in the source - image. If the image has more than one band, the histograms for - all bands are concatenated (for example, the histogram for an - "RGB" image contains 768 values). - - A bilevel image (mode "1") is treated as a greyscale ("L") image + Returns a histogram for the image. The histogram is returned as a + list of pixel counts, one for each pixel value in the source + image. Counts are grouped into 256 bins for each band, even if + the image has more than 8 bits per band. If the image has more + than one band, the histograms for all bands are concatenated (for + example, the histogram for an "RGB" image contains 768 values). + + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method returns a histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be either a - bi-level image (mode "1") or a greyscale image ("L"). + bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1476,22 +1732,24 @@ def histogram(self, mask=None, extrema=None): mask.load() return self.im.histogram((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.histogram(extrema) + return self.im.histogram( + extrema if extrema is not None else self.getextrema() + ) return self.im.histogram() - def entropy(self, mask=None, extrema=None): + def entropy( + self, mask: Image | None = None, extrema: tuple[float, float] | None = None + ) -> float: """ Calculates and returns the entropy for the image. - A bilevel image (mode "1") is treated as a greyscale ("L") + A bilevel image (mode "1") is treated as a grayscale ("L") image by this method. If a mask is provided, the method employs the histogram for those parts of the image where the mask image is non-zero. The mask image must have the same size as the image, and be - either a bi-level image (mode "1") or a greyscale image ("L"). + either a bi-level image (mode "1") or a grayscale image ("L"). :param mask: An optional mask. :param extrema: An optional tuple of manually-specified extrema. @@ -1502,12 +1760,17 @@ def entropy(self, mask=None, extrema=None): mask.load() return self.im.entropy((0, 0), mask.im) if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.entropy(extrema) + return self.im.entropy( + extrema if extrema is not None else self.getextrema() + ) return self.im.entropy() - def paste(self, im, box=None, mask=None): + def paste( + self, + im: Image | str | float | tuple[float, ...], + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -1525,8 +1788,8 @@ def paste(self, im, box=None, mask=None): also use color strings as supported by the ImageColor module. If a mask is given, this method updates only the regions - indicated by the mask. You can use either "1", "L" or "RGBA" - images (in the latter case, the alpha band is used as mask). + indicated by the mask. You can use either "1", "L", "LA", "RGBA" + or "RGBa" images (if present, the alpha band is used as mask). Where the mask is 255, the given image is copied as is. Where the mask is 0, the current value is preserved. Intermediate values will mix the two images together, including their alpha @@ -1535,7 +1798,7 @@ def paste(self, im, box=None, mask=None): See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to combine images with respect to their alpha channels. - :param im: Source image or pixel value (integer or tuple). + :param im: Source image or pixel value (integer, float or tuple). :param box: An optional 4-tuple giving the region to paste into. If a 2-tuple is used instead, it's treated as the upper left corner. If omitted or None, the source is pasted into the @@ -1547,7 +1810,10 @@ def paste(self, im, box=None, mask=None): :param mask: An optional mask image. """ - if isImageType(box) and mask is None: + if isinstance(box, Image): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None @@ -1557,37 +1823,42 @@ def paste(self, im, box=None, mask=None): if len(box) == 2: # upper left corner given; get size from image or mask - if isImageType(im): + if isinstance(im, Image): size = im.size - elif isImageType(mask): + elif isinstance(mask, Image): size = mask.size else: # FIXME: use self.size here? - raise ValueError("cannot determine region size; use 4-item box") + msg = "cannot determine region size; use 4-item box" + raise ValueError(msg) box += (box[0] + size[0], box[1] + size[1]) + source: core.ImagingCore | str | float | tuple[float, ...] if isinstance(im, str): from . import ImageColor - im = ImageColor.getcolor(im, self.mode) - - elif isImageType(im): + source = ImageColor.getcolor(im, self.mode) + elif isinstance(im, Image): im.load() if self.mode != im.mode: - if self.mode != "RGB" or im.mode not in ("RGBA", "RGBa"): + if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"): # should use an adapter for this! im = im.convert(self.mode) - im = im.im + source = im.im + else: + source = im self._ensure_mutable() if mask: mask.load() - self.im.paste(im, box, mask.im) + self.im.paste(source, box, mask.im) else: - self.im.paste(im, box) + self.im.paste(source, box) - def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: """'In-place' analog of Image.alpha_composite. Composites an image onto this image. @@ -1602,27 +1873,35 @@ def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): """ if not isinstance(source, (list, tuple)): - raise ValueError("Source must be a tuple") + msg = "Source must be a list or tuple" + raise ValueError(msg) if not isinstance(dest, (list, tuple)): - raise ValueError("Destination must be a tuple") - if not len(source) in (2, 4): - raise ValueError("Source must be a 2 or 4-tuple") + msg = "Destination must be a list or tuple" + raise ValueError(msg) + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" + raise ValueError(msg) + if not len(dest) == 2: - raise ValueError("Destination must be a 2-tuple") + msg = "Destination must be a sequence of length 2" + raise ValueError(msg) if min(source) < 0: - raise ValueError("Source must be non-negative") - - if len(source) == 2: - source = source + im.size + msg = "Source must be non-negative" + raise ValueError(msg) - # over image, crop if it's not the whole thing. - if source == (0, 0) + im.size: + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: overlay = im else: - overlay = im.crop(source) + overlay = im.crop(overlay_crop_box) # target for the paste - box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) # destination image. don't copy if we're using the whole image. if box == (0, 0) + self.size: @@ -1633,7 +1912,17 @@ def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode=None): + def point( + self, + lut: ( + Sequence[float] + | NumpyArray + | Callable[[int], float] + | Callable[[ImagePointTransform], ImagePointTransform | float] + | ImagePointHandler + ), + mode: str | None = None, + ) -> Image: """ Maps this image through a lookup table or function. @@ -1648,11 +1937,10 @@ def point(self, lut, mode=None): object:: class Example(Image.ImagePointHandler): - def point(self, data): + def point(self, im: Image) -> Image: # Return result - :param mode: Output mode (default is same as input). In the - current version, this can only be used if the source image - has mode "L" or "P", and the output has mode "1" or the + :param mode: Output mode (default is same as input). This can only be used if + the source image has mode "L" or "P", and the output has mode "1" or the source image mode is "I" and the output mode is "L". :returns: An :py:class:`~PIL.Image.Image` object. """ @@ -1668,26 +1956,30 @@ def point(self, data): # check if the function can be used with point_transform # UNDONE wiredfool -- I think this prevents us from ever doing # a gamma function point transform on > 8bit images. - scale, offset = _getscaleoffset(lut) + scale, offset = _getscaleoffset(lut) # type: ignore[arg-type] return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - lut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands # type: ignore[arg-type] + else: + flatLut = lut if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case - raise ValueError("point operation not supported for this mode") + msg = "point operation not supported for this mode" + raise ValueError(msg) - return self._new(self.im.point(lut, mode)) + if mode != "F": + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) - def putalpha(self, alpha): + def putalpha(self, alpha: Image | int) -> None: """ Adds or replaces the alpha layer in this image. If the image does not have an alpha layer, it's converted to "LA" or "RGBA". The new layer must be either "L" or "1". :param alpha: The new alpha layer. This can either be an "L" or "1" - image having the same size as this image, or an integer or - other color value. + image having the same size as this image, or an integer. """ self._ensure_mutable() @@ -1702,22 +1994,24 @@ def putalpha(self, alpha): # do things the hard way im = self.im.convert(mode) if im.mode not in ("LA", "PA", "RGBA"): - raise ValueError from e # sanity check + msg = "alpha channel could not be added" + raise ValueError(msg) from e # sanity check self.im = im - self.pyaccess = None - self.mode = self.im.mode + self._mode = self.im.mode except KeyError as e: - raise ValueError("illegal image mode") from e + msg = "illegal image mode" + raise ValueError(msg) from e if self.mode in ("LA", "PA"): band = 1 else: band = 3 - if isImageType(alpha): + if isinstance(alpha, Image): # alpha layer if alpha.mode not in ("1", "L"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) alpha.load() if alpha.mode == "1": alpha = alpha.convert("L") @@ -1733,15 +2027,21 @@ def putalpha(self, alpha): self.im.putband(alpha.im, band) - def putdata(self, data, scale=1.0, offset=0.0): + def putdata( + self, + data: Sequence[float] | Sequence[Sequence[int]] | core.ImagingCore | NumpyArray, + scale: float = 1.0, + offset: float = 0.0, + ) -> None: """ - Copies pixel data to this image. This method copies data from a - sequence object into the image, starting at the upper left - corner (0, 0), and continuing until either the image or the - sequence ends. The scale and offset values are used to adjust - the sequence values: **pixel = value*scale + offset**. - - :param data: A sequence object. + Copies pixel data from a flattened sequence object into the image. The + values should start at the upper left corner (0, 0), continue to the + end of the line, followed directly by the first value of the second + line, and so on. Data will be read until either the image or the + sequence ends. The scale and offset values are used to adjust the + sequence values: **pixel = value*scale + offset**. + + :param data: A flattened sequence object. :param scale: An optional scale value. The default is 1.0. :param offset: An optional offset value. The default is 0.0. """ @@ -1750,7 +2050,11 @@ def putdata(self, data, scale=1.0, offset=0.0): self.im.putdata(data, scale, offset) - def putpalette(self, data, rawmode="RGB"): + def putpalette( + self, + data: ImagePalette.ImagePalette | bytes | Sequence[int], + rawmode: str = "RGB", + ) -> None: """ Attaches a palette to this image. The image must be a "P", "PA", "L" or "LA" image. @@ -1766,30 +2070,37 @@ def putpalette(self, data, rawmode="RGB"): Alternatively, an 8-bit string may be used instead of an integer sequence. :param data: A palette sequence (either a list or a string). - :param rawmode: The raw mode of the palette. Either "RGB", "RGBA", or a - mode that can be transformed to "RGB" (e.g. "R", "BGR;15", "RGBA;L"). + :param rawmode: The raw mode of the palette. Either "RGB", "RGBA", or a mode + that can be transformed to "RGB" or "RGBA" (e.g. "R", "BGR;15", "RGBA;L"). """ from . import ImagePalette if self.mode not in ("L", "LA", "P", "PA"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) if isinstance(data, ImagePalette.ImagePalette): - palette = ImagePalette.raw(data.rawmode, data.palette) + if data.rawmode is not None: + palette = ImagePalette.raw(data.rawmode, data.palette) + else: + palette = ImagePalette.ImagePalette(palette=data.palette) + palette.dirty = 1 else: if not isinstance(data, bytes): data = bytes(data) palette = ImagePalette.raw(rawmode, data) - self.mode = "PA" if "A" in self.mode else "P" + self._mode = "PA" if "A" in self.mode else "P" self.palette = palette - self.palette.mode = "RGB" + self.palette.mode = "RGBA" if "A" in rawmode else "RGB" self.load() # install new palette - def putpixel(self, xy, value): + def putpixel( + self, xy: tuple[int, int], value: float | tuple[int, ...] | list[int] + ) -> None: """ Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for multi-band images. In addition to this, RGB and RGBA tuples are - accepted for P images. + accepted for P and PA images. Note that this method is relatively slow. For more extensive changes, use :py:meth:`~PIL.Image.Image.paste` or the :py:mod:`~PIL.ImageDraw` @@ -1810,19 +2121,23 @@ def putpixel(self, xy, value): self._copy() self.load() - if self.pyaccess: - return self.pyaccess.putpixel(xy, value) - if ( - self.mode == "P" + self.mode in ("P", "PA") and isinstance(value, (list, tuple)) and len(value) in [3, 4] ): - # RGB or RGBA value for a P image - value = self.palette.getcolor(value, self) + # RGB or RGBA value for a P or PA image + if self.mode == "PA": + alpha = value[3] if len(value) == 4 else 255 + value = value[:3] + assert self.palette is not None + palette_index = self.palette.getcolor(tuple(value), self) + value = (palette_index, alpha) if self.mode == "PA" else palette_index return self.im.putpixel(xy, value) - def remap_palette(self, dest_map, source_palette=None): + def remap_palette( + self, dest_map: list[int], source_palette: bytes | bytearray | None = None + ) -> Image: """ Rewrites the image to reorder the palette. @@ -1836,21 +2151,32 @@ def remap_palette(self, dest_map, source_palette=None): from . import ImagePalette if self.mode not in ("L", "P"): - raise ValueError("illegal image mode") + msg = "illegal image mode" + raise ValueError(msg) + bands = 3 + palette_mode = "RGB" if source_palette is None: if self.mode == "P": self.load() - source_palette = self.im.getpalette("RGB")[:768] + palette_mode = self.im.getpalettemode() + if palette_mode == "RGBA": + bands = 4 + source_palette = self.im.getpalette(palette_mode, palette_mode) else: # L-mode source_palette = bytearray(i // 3 for i in range(768)) + elif len(source_palette) > 768: + bands = 4 + palette_mode = "RGBA" palette_bytes = b"" new_positions = [0] * 256 # pick only the used colors from the palette for i, oldPosition in enumerate(dest_map): - palette_bytes += source_palette[oldPosition * 3 : oldPosition * 3 + 3] + palette_bytes += source_palette[ + oldPosition * bands : oldPosition * bands + bands + ] new_positions[oldPosition] = i # replace the palette color id of all pixel with the new id @@ -1874,25 +2200,37 @@ def remap_palette(self, dest_map, source_palette=None): mapping_palette = bytearray(new_positions) m_im = self.copy() - m_im.mode = "P" + m_im._mode = "P" - m_im.palette = ImagePalette.ImagePalette("RGB", palette=mapping_palette * 3) + m_im.palette = ImagePalette.ImagePalette( + palette_mode, palette=mapping_palette * bands + ) # possibly set palette dirty, then # m_im.putpalette(mapping_palette, 'L') # converts to 'P' # or just force it. # UNDONE -- this is part of the general issue with palettes - m_im.im.putpalette("RGB;L", m_im.palette.tobytes()) + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) m_im = m_im.convert("L") - # Internally, we require 768 bytes for a palette. - new_palette_bytes = palette_bytes + (768 - len(palette_bytes)) * b"\x00" - m_im.putpalette(new_palette_bytes) - m_im.palette = ImagePalette.ImagePalette("RGB", palette=palette_bytes) + m_im.putpalette(palette_bytes, palette_mode) + m_im.palette = ImagePalette.ImagePalette(palette_mode, palette=palette_bytes) + + if "transparency" in self.info: + try: + m_im.info["transparency"] = dest_map.index(self.info["transparency"]) + except ValueError: + if "transparency" in m_im.info: + del m_im.info["transparency"] return m_im - def _get_safe_box(self, size, resample, box): + def _get_safe_box( + self, + size: tuple[int, int], + resample: Resampling, + box: tuple[float, float, float, float], + ) -> tuple[int, int, int, int]: """Expands the box so it includes adjacent pixels that may be used by resampling with the given resampling filter. """ @@ -1909,22 +2247,27 @@ def _get_safe_box(self, size, resample, box): min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None): + def resize( + self, + size: tuple[int, int] | list[int] | NumpyArray, + resample: int | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: """ Returns a resized copy of this image. - :param size: The requested size in pixels, as a 2-tuple: + :param size: The requested size in pixels, as a tuple or array: (width, height). :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.NEAREST`, :py:data:`PIL.Image.BOX`, - :py:data:`PIL.Image.BILINEAR`, :py:data:`PIL.Image.HAMMING`, - :py:data:`PIL.Image.BICUBIC` or :py:data:`PIL.Image.LANCZOS`. + one of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. If the image has mode "1" or "P", it is always set to - :py:data:`PIL.Image.NEAREST`. - If the image mode specifies a number of bits, such as "I;16", then the - default filter is :py:data:`PIL.Image.NEAREST`. - Otherwise, the default filter is :py:data:`PIL.Image.BICUBIC`. - See: :ref:`concept-filters`. + :py:data:`Resampling.NEAREST`. If the image mode is "BGR;15", + "BGR;16" or "BGR;24", then the default filter is + :py:data:`Resampling.NEAREST`. Otherwise, the default filter is + :py:data:`Resampling.BICUBIC`. See: :ref:`concept-filters`. :param box: An optional 4-tuple of floats providing the source image region to be scaled. The values must be within (0, 0, width, height) rectangle. @@ -1945,59 +2288,64 @@ def resize(self, size, resample=None, box=None, reducing_gap=None): """ if resample is None: - type_special = ";" in self.mode - resample = NEAREST if type_special else BICUBIC - elif resample not in (NEAREST, BILINEAR, BICUBIC, LANCZOS, BOX, HAMMING): - message = f"Unknown resampling filter ({resample})." + bgr = self.mode.startswith("BGR;") + resample = Resampling.NEAREST if bgr else Resampling.BICUBIC + elif resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + Resampling.LANCZOS, + Resampling.BOX, + Resampling.HAMMING, + ): + msg = f"Unknown resampling filter ({resample})." filters = [ - "{} ({})".format(filter[1], filter[0]) + f"{filter[1]} ({filter[0]})" for filter in ( - (NEAREST, "Image.NEAREST"), - (LANCZOS, "Image.LANCZOS"), - (BILINEAR, "Image.BILINEAR"), - (BICUBIC, "Image.BICUBIC"), - (BOX, "Image.BOX"), - (HAMMING, "Image.HAMMING"), + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.LANCZOS, "Image.Resampling.LANCZOS"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), + (Resampling.BOX, "Image.Resampling.BOX"), + (Resampling.HAMMING, "Image.Resampling.HAMMING"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) if reducing_gap is not None and reducing_gap < 1.0: - raise ValueError("reducing_gap must be 1.0 or greater") - - size = tuple(size) + msg = "reducing_gap must be 1.0 or greater" + raise ValueError(msg) if box is None: box = (0, 0) + self.size - else: - box = tuple(box) + size = tuple(size) if self.size == size and box == (0, 0) + self.size: return self.copy() if self.mode in ("1", "P"): - resample = NEAREST + resample = Resampling.NEAREST - if self.mode in ["LA", "RGBA"] and resample != NEAREST: + if self.mode in ["LA", "RGBA"] and resample != Resampling.NEAREST: im = self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) im = im.resize(size, resample, box) return im.convert(self.mode) self.load() - if reducing_gap is not None and resample != NEAREST: + if reducing_gap is not None and resample != Resampling.NEAREST: factor_x = int((box[2] - box[0]) / size[0] / reducing_gap) or 1 factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1 if factor_x > 1 or factor_y > 1: - reduce_box = self._get_safe_box(size, resample, box) + reduce_box = self._get_safe_box(size, cast(Resampling, resample), box) factor = (factor_x, factor_y) - if callable(self.reduce): - self = self.reduce(factor, box=reduce_box) - else: - self = Image.reduce(self, factor, box=reduce_box) + self = ( + self.reduce(factor, box=reduce_box) + if callable(self.reduce) + else Image.reduce(self, factor, box=reduce_box) + ) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, @@ -2007,7 +2355,11 @@ def resize(self, size, resample=None, box=None, reducing_gap=None): return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor, box=None): + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -2025,8 +2377,6 @@ def reduce(self, factor, box=None): if box is None: box = (0, 0) + self.size - else: - box = tuple(box) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() @@ -2042,13 +2392,13 @@ def reduce(self, factor, box=None): def rotate( self, - angle, - resample=NEAREST, - expand=0, - center=None, - translate=None, - fillcolor=None, - ): + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, + center: tuple[float, float] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Returns a rotated copy of this image. This method returns a copy of this image, rotated the given number of degrees counter @@ -2056,12 +2406,12 @@ def rotate( :param angle: In degrees counter clockwise. :param resample: An optional resampling filter. This can be - one of :py:data:`PIL.Image.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.BICUBIC` - (cubic spline interpolation in a 4x4 environment). - If omitted, or if the image has mode "1" or "P", it is - set to :py:data:`PIL.Image.NEAREST`. See :ref:`concept-filters`. + one of :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline + interpolation in a 4x4 environment). If omitted, or if the image has + mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. + See :ref:`concept-filters`. :param expand: Optional expansion flag. If true, expands the output image to make it large enough to hold the entire rotated image. If false or omitted, make the output image the same size as the @@ -2082,9 +2432,11 @@ def rotate( if angle == 0: return self.copy() if angle == 180: - return self.transpose(ROTATE_180) + return self.transpose(Transpose.ROTATE_180) if angle in (90, 270) and (expand or self.width == self.height): - return self.transpose(ROTATE_90 if angle == 90 else ROTATE_270) + return self.transpose( + Transpose.ROTATE_90 if angle == 90 else Transpose.ROTATE_270 + ) # Calculate the affine matrix. Note that this is the reverse # transformation (from destination image to source) because we @@ -2111,10 +2463,7 @@ def rotate( else: post_trans = translate if center is None: - # FIXME These should be rounded to ints? - rotn_center = (w / 2.0, h / 2.0) - else: - rotn_center = center + center = (w / 2, h / 2) angle = -math.radians(angle) matrix = [ @@ -2126,24 +2475,24 @@ def rotate( 0.0, ] - def transform(x, y, matrix): + def transform(x: float, y: float, matrix: list[float]) -> tuple[float, float]: (a, b, c, d, e, f) = matrix return a * x + b * y + c, d * x + e * y + f matrix[2], matrix[5] = transform( - -rotn_center[0] - post_trans[0], -rotn_center[1] - post_trans[1], matrix + -center[0] - post_trans[0], -center[1] - post_trans[1], matrix ) - matrix[2] += rotn_center[0] - matrix[5] += rotn_center[1] + matrix[2] += center[0] + matrix[5] += center[1] if expand: # calculate output size xx = [] yy = [] for x, y in ((0, 0), (w, 0), (w, h), (0, h)): - x, y = transform(x, y, matrix) - xx.append(x) - yy.append(y) + transformed_x, transformed_y = transform(x, y, matrix) + xx.append(transformed_x) + yy.append(transformed_y) nw = math.ceil(max(xx)) - math.floor(min(xx)) nh = math.ceil(max(yy)) - math.floor(min(yy)) @@ -2153,9 +2502,13 @@ def transform(x, y, matrix): matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix) w, h = nw, nh - return self.transform((w, h), AFFINE, matrix, resample, fillcolor=fillcolor) + return self.transform( + (w, h), Transform.AFFINE, matrix, resample, fillcolor=fillcolor + ) - def save(self, fp, format=None, **params): + def save( + self, fp: StrOrBytesPath | IO[bytes], format: str | None = None, **params: Any + ) -> None: """ Saves this image under the given filename. If no format is specified, the format to use is determined from the filename @@ -2172,7 +2525,7 @@ def save(self, fp, format=None, **params): implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filename (string), pathlib.Path object or file object. + :param fp: A filename (string), os.PathLike object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2185,33 +2538,31 @@ def save(self, fp, format=None, **params): may have been created, and may contain partial data. """ - filename = "" + filename: str | bytes = "" open_fp = False - if isinstance(fp, Path): - filename = str(fp) - open_fp = True - elif isPath(fp): - filename = fp + if is_path(fp): + filename = os.fspath(fp) open_fp = True elif fp == sys.stdout: try: fp = sys.stdout.buffer except AttributeError: pass - if not filename and hasattr(fp, "name") and isPath(fp.name): + if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes - filename = fp.name + filename = os.fspath(fp.name) # may mutate self! self._ensure_mutable() save_all = params.pop("save_all", False) - self.encoderinfo = params - self.encoderconfig = () + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} + self.encoderconfig: tuple[Any, ...] = () preinit() - ext = os.path.splitext(filename)[1].lower() + filename_ext = os.path.splitext(filename)[1].lower() + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext if not format: if ext not in EXTENSION: @@ -2219,7 +2570,8 @@ def save(self, fp, format=None, **params): try: format = EXTENSION[ext] except KeyError as e: - raise ValueError(f"unknown file extension: {ext}") from e + msg = f"unknown file extension: {ext}" + raise ValueError(msg) from e if format.upper() not in SAVE: init() @@ -2228,22 +2580,38 @@ def save(self, fp, format=None, **params): else: save_handler = SAVE[format.upper()] + created = False if open_fp: + created = not os.path.exists(filename) if params.get("append", False): # Open also for reading ("+"), because TIFF save_all # writer needs to go back and edit the written data. fp = builtins.open(filename, "r+b") else: fp = builtins.open(filename, "w+b") + else: + fp = cast(IO[bytes], fp) try: save_handler(self, fp, filename) - finally: - # do what we can to clean up + except Exception: if open_fp: fp.close() + if created: + try: + os.remove(filename) + except PermissionError: + pass + raise + finally: + try: + del self.encoderinfo + except AttributeError: + pass + if open_fp: + fp.close() - def seek(self, frame): + def seek(self, frame: int) -> None: """ Seeks to the given frame in this sequence file. If you seek beyond the end of the sequence, the method raises an @@ -2262,9 +2630,10 @@ def seek(self, frame): # overridden by file handlers if frame != 0: - raise EOFError + msg = "no more images in file" + raise EOFError(msg) - def show(self, title=None, command=None): + def show(self, title: str | None = None) -> None: """ Displays this image. This method is mainly intended for debugging purposes. @@ -2274,8 +2643,8 @@ def show(self, title=None, command=None): The image is first saved to a temporary file. By default, it will be in PNG format. - On Unix, the image is then opened using the **display**, **eog** or - **xv** utility, depending on which one can be found. + On Unix, the image is then opened using the **xdg-open**, **display**, + **gm**, **eog** or **xv** utility, depending on which one can be found. On macOS, the image is opened with the native Preview application. @@ -2284,16 +2653,9 @@ def show(self, title=None, command=None): :param title: Optional title to use for the image window, where possible. """ - if command is not None: - warnings.warn( - "The command parameter is deprecated and will be removed in Pillow 9 " - "(2022-01-02). Use a subclass of ImageShow.Viewer instead.", - DeprecationWarning, - ) - - _show(self, title=title, command=command) + _show(self, title=title) - def split(self): + def split(self) -> tuple[Image, ...]: """ Split this image into individual bands. This method returns a tuple of individual image bands from an image. For example, @@ -2309,12 +2671,10 @@ def split(self): self.load() if self.im.bands == 1: - ims = [self.copy()] - else: - ims = map(self._new, self.im.split()) - return tuple(ims) + return (self.copy(),) + return tuple(map(self._new, self.im.split())) - def getchannel(self, channel): + def getchannel(self, channel: int | str) -> Image: """ Returns an image containing a single channel of the source image. @@ -2331,11 +2691,12 @@ def getchannel(self, channel): try: channel = self.getbands().index(channel) except ValueError as e: - raise ValueError(f'The image has no channel "{channel}"') from e + msg = f'The image has no channel "{channel}"' + raise ValueError(msg) from e return self._new(self.im.getband(channel)) - def tell(self): + def tell(self) -> int: """ Returns the current frame number. See :py:meth:`~PIL.Image.Image.seek`. @@ -2346,7 +2707,12 @@ def tell(self): """ return 0 - def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): + def thumbnail( + self, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float | None = 2.0, + ) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than @@ -2360,13 +2726,14 @@ def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): apply this method to a :py:meth:`~PIL.Image.Image.copy` of the original image. - :param size: Requested size. + :param size: The requested size in pixels, as a 2-tuple: + (width, height). :param resample: Optional resampling filter. This can be one - of :py:data:`PIL.Image.NEAREST`, :py:data:`PIL.Image.BOX`, - :py:data:`PIL.Image.BILINEAR`, :py:data:`PIL.Image.HAMMING`, - :py:data:`PIL.Image.BICUBIC` or :py:data:`PIL.Image.LANCZOS`. - If omitted, it defaults to :py:data:`PIL.Image.BICUBIC`. - (was :py:data:`PIL.Image.NEAREST` prior to version 2.5.0). + of :py:data:`Resampling.NEAREST`, :py:data:`Resampling.BOX`, + :py:data:`Resampling.BILINEAR`, :py:data:`Resampling.HAMMING`, + :py:data:`Resampling.BICUBIC` or :py:data:`Resampling.LANCZOS`. + If omitted, it defaults to :py:data:`Resampling.BICUBIC`. + (was :py:data:`Resampling.NEAREST` prior to version 2.5.0). See: :ref:`concept-filters`. :param reducing_gap: Apply optimization by resizing the image in two steps. First, reducing the image by integer times @@ -2385,56 +2752,71 @@ def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0): :returns: None """ - x, y = map(math.floor, size) - if x >= self.width and y >= self.height: - return + provided_size = tuple(map(math.floor, size)) - def round_aspect(number, key): - return max(min(math.floor(number), math.ceil(number), key=key), 1) + def preserve_aspect_ratio() -> tuple[int, int] | None: + def round_aspect(number: float, key: Callable[[int], float]) -> int: + return max(min(math.floor(number), math.ceil(number), key=key), 1) - # preserve aspect ratio - aspect = self.width / self.height - if x / y >= aspect: - x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) - else: - y = round_aspect( - x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) - ) - size = (x, y) + x, y = provided_size + if x >= self.width and y >= self.height: + return None + + aspect = self.width / self.height + if x / y >= aspect: + x = round_aspect(y * aspect, key=lambda n: abs(aspect - n / y)) + else: + y = round_aspect( + x / aspect, key=lambda n: 0 if n == 0 else abs(aspect - x / n) + ) + return x, y + + preserved_size = preserve_aspect_ratio() + if preserved_size is None: + return + final_size = preserved_size box = None if reducing_gap is not None: - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) if res is not None: box = res[1] - if self.size != size: - im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) self.im = im.im - self._size = size - self.mode = self.im.mode + self._size = final_size + self._mode = self.im.mode self.readonly = 0 - self.pyaccess = None # FIXME: the different transform methods need further explanation # instead of bloating the method docs, add a separate chapter. def transform( - self, size, method, data=None, resample=NEAREST, fill=1, fillcolor=None - ): + self, + size: tuple[int, int], + method: Transform | ImageTransformHandler | SupportsGetData, + data: Sequence[Any] | None = None, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Transforms this image. This method creates a new image with the given size, and the same mode as the original, and copies data to the new image using the given transform. - :param size: The output size. + :param size: The output size in pixels, as a 2-tuple: + (width, height). :param method: The transformation method. This is one of - :py:data:`PIL.Image.EXTENT` (cut out a rectangular subregion), - :py:data:`PIL.Image.AFFINE` (affine transform), - :py:data:`PIL.Image.PERSPECTIVE` (perspective transform), - :py:data:`PIL.Image.QUAD` (map a quadrilateral to a rectangle), or - :py:data:`PIL.Image.MESH` (map a number of source quadrilaterals + :py:data:`Transform.EXTENT` (cut out a rectangular subregion), + :py:data:`Transform.AFFINE` (affine transform), + :py:data:`Transform.PERSPECTIVE` (perspective transform), + :py:data:`Transform.QUAD` (map a quadrilateral to a rectangle), or + :py:data:`Transform.MESH` (map a number of source quadrilaterals in one operation). It may also be an :py:class:`~PIL.Image.ImageTransformHandler` @@ -2444,21 +2826,25 @@ class Example(Image.ImageTransformHandler): def transform(self, size, data, resample, fill=1): # Return result + Implementations of :py:class:`~PIL.Image.ImageTransformHandler` + for some of the :py:class:`Transform` methods are provided + in :py:mod:`~PIL.ImageTransform`. + It may also be an object with a ``method.getdata`` method that returns a tuple supplying new ``method`` and ``data`` values:: class Example: def getdata(self): - method = Image.EXTENT + method = Image.Transform.EXTENT data = (0, 0, 100, 100) return method, data :param data: Extra data to the transformation method. :param resample: Optional resampling filter. It can be one of - :py:data:`PIL.Image.NEAREST` (use nearest neighbour), - :py:data:`PIL.Image.BILINEAR` (linear interpolation in a 2x2 - environment), or :py:data:`PIL.Image.BICUBIC` (cubic spline + :py:data:`Resampling.NEAREST` (use nearest neighbour), + :py:data:`Resampling.BILINEAR` (linear interpolation in a 2x2 + environment), or :py:data:`Resampling.BICUBIC` (cubic spline interpolation in a 4x4 environment). If omitted, or if the image - has mode "1" or "P", it is set to :py:data:`PIL.Image.NEAREST`. + has mode "1" or "P", it is set to :py:data:`Resampling.NEAREST`. See: :ref:`concept-filters`. :param fill: If ``method`` is an :py:class:`~PIL.Image.ImageTransformHandler` object, this is one of @@ -2468,7 +2854,7 @@ def getdata(self): :returns: An :py:class:`~PIL.Image.Image` object. """ - if self.mode in ("LA", "RGBA") and resample != NEAREST: + if self.mode in ("LA", "RGBA") and resample != Resampling.NEAREST: return ( self.convert({"LA": "La", "RGBA": "RGBa"}[self.mode]) .transform(size, method, data, resample, fill, fillcolor) @@ -2483,16 +2869,19 @@ def getdata(self): method, data = method.getdata() if data is None: - raise ValueError("missing method data") + msg = "missing method data" + raise ValueError(msg) im = new(self.mode, size, fillcolor) if self.mode == "P" and self.palette: im.palette = self.palette.copy() im.info = self.info.copy() - if method == MESH: + if method == Transform.MESH: # list of quads for box, quad in data: - im.__transformer(box, self, QUAD, quad, resample, fillcolor is None) + im.__transformer( + box, self, Transform.QUAD, quad, resample, fillcolor is None + ) else: im.__transformer( (0, 0) + size, self, method, data, resample, fillcolor is None @@ -2500,28 +2889,36 @@ def getdata(self): return im - def __transformer(self, box, image, method, data, resample=NEAREST, fill=1): + def __transformer( + self, + box: tuple[int, int, int, int], + image: Image, + method: Transform, + data: Sequence[float], + resample: int = Resampling.NEAREST, + fill: bool = True, + ) -> None: w = box[2] - box[0] h = box[3] - box[1] - if method == AFFINE: - data = data[0:6] + if method == Transform.AFFINE: + data = data[:6] - elif method == EXTENT: + elif method == Transform.EXTENT: # convert extent to an affine transform x0, y0, x1, y1 = data xs = (x1 - x0) / w ys = (y1 - y0) / h - method = AFFINE + method = Transform.AFFINE data = (xs, 0, x0, 0, ys, y0) - elif method == PERSPECTIVE: - data = data[0:8] + elif method == Transform.PERSPECTIVE: + data = data[:8] - elif method == QUAD: + elif method == Transform.QUAD: # quadrilateral warp. data specifies the four corners # given as NW, SW, SE, and NE. - nw = data[0:2] + nw = data[:2] sw = data[2:4] se = data[4:6] ne = data[6:8] @@ -2540,54 +2937,59 @@ def __transformer(self, box, image, method, data, resample=NEAREST, fill=1): ) else: - raise ValueError("unknown transformation method") - - if resample not in (NEAREST, BILINEAR, BICUBIC): - if resample in (BOX, HAMMING, LANCZOS): - message = { - BOX: "Image.BOX", - HAMMING: "Image.HAMMING", - LANCZOS: "Image.LANCZOS/Image.ANTIALIAS", - }[resample] + f" ({resample}) cannot be used." + msg = "unknown transformation method" + raise ValueError(msg) + + if resample not in ( + Resampling.NEAREST, + Resampling.BILINEAR, + Resampling.BICUBIC, + ): + if resample in (Resampling.BOX, Resampling.HAMMING, Resampling.LANCZOS): + unusable: dict[int, str] = { + Resampling.BOX: "Image.Resampling.BOX", + Resampling.HAMMING: "Image.Resampling.HAMMING", + Resampling.LANCZOS: "Image.Resampling.LANCZOS", + } + msg = unusable[resample] + f" ({resample}) cannot be used." else: - message = f"Unknown resampling filter ({resample})." + msg = f"Unknown resampling filter ({resample})." filters = [ - "{} ({})".format(filter[1], filter[0]) + f"{filter[1]} ({filter[0]})" for filter in ( - (NEAREST, "Image.NEAREST"), - (BILINEAR, "Image.BILINEAR"), - (BICUBIC, "Image.BICUBIC"), + (Resampling.NEAREST, "Image.Resampling.NEAREST"), + (Resampling.BILINEAR, "Image.Resampling.BILINEAR"), + (Resampling.BICUBIC, "Image.Resampling.BICUBIC"), ) ] - raise ValueError( - message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1] - ) + msg += f" Use {', '.join(filters[:-1])} or {filters[-1]}" + raise ValueError(msg) image.load() self.load() if image.mode in ("1", "P"): - resample = NEAREST + resample = Resampling.NEAREST - self.im.transform2(box, image.im, method, data, resample, fill) + self.im.transform(box, image.im, method, data, resample, fill) - def transpose(self, method): + def transpose(self, method: Transpose) -> Image: """ Transpose image (flip or rotate in 90 degree steps) - :param method: One of :py:data:`PIL.Image.FLIP_LEFT_RIGHT`, - :py:data:`PIL.Image.FLIP_TOP_BOTTOM`, :py:data:`PIL.Image.ROTATE_90`, - :py:data:`PIL.Image.ROTATE_180`, :py:data:`PIL.Image.ROTATE_270`, - :py:data:`PIL.Image.TRANSPOSE` or :py:data:`PIL.Image.TRANSVERSE`. + :param method: One of :py:data:`Transpose.FLIP_LEFT_RIGHT`, + :py:data:`Transpose.FLIP_TOP_BOTTOM`, :py:data:`Transpose.ROTATE_90`, + :py:data:`Transpose.ROTATE_180`, :py:data:`Transpose.ROTATE_270`, + :py:data:`Transpose.TRANSPOSE` or :py:data:`Transpose.TRANSVERSE`. :returns: Returns a flipped or rotated copy of this image. """ self.load() return self._new(self.im.transpose(method)) - def effect_spread(self, distance): + def effect_spread(self, distance: int) -> Image: """ Randomly spread pixels in an image. @@ -2596,20 +2998,22 @@ def effect_spread(self, distance): self.load() return self._new(self.im.effect_spread(distance)) - def toqimage(self): + def toqimage(self) -> ImageQt.ImageQt: """Returns a QImage copy of this image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqimage(self) - def toqpixmap(self): + def toqpixmap(self) -> ImageQt.QPixmap: """Returns a QPixmap copy of this image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.toqpixmap(self) @@ -2623,7 +3027,9 @@ class ImagePointHandler: (for use with :py:meth:`~PIL.Image.Image.point`) """ - pass + @abc.abstractmethod + def point(self, im: Image) -> Image: + pass class ImageTransformHandler: @@ -2632,7 +3038,14 @@ class ImageTransformHandler: (for use with :py:meth:`~PIL.Image.Image.transform`) """ - pass + @abc.abstractmethod + def transform( + self, + size: tuple[int, int], + image: Image, + **options: Any, + ) -> Image: + pass # -------------------------------------------------------------------- @@ -2642,31 +3055,36 @@ class ImageTransformHandler: # Debugging -def _wedge(): - """Create greyscale wedge (for debugging only)""" +def _wedge() -> Image: + """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) -def _check_size(size): +def _check_size(size: Any) -> None: """ Common check to enforce type and sanity check on size tuples :param size: Should be a 2 tuple of (width, height) - :returns: True, or raises a ValueError + :returns: None, or raises a ValueError """ if not isinstance(size, (list, tuple)): - raise ValueError("Size must be a tuple") + msg = "Size must be a list or tuple" + raise ValueError(msg) if len(size) != 2: - raise ValueError("Size must be a tuple of length 2") + msg = "Size must be a sequence of length 2" + raise ValueError(msg) if size[0] < 0 or size[1] < 0: - raise ValueError("Width and height must be >= 0") - - return True + msg = "Width and height must be >= 0" + raise ValueError(msg) -def new(mode, size, color=0): +def new( + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, +) -> Image: """ Creates a new image with the given mode and size. @@ -2676,12 +3094,15 @@ def new(mode, size, color=0): :param color: What color to use for the image. Default is black. If given, this should be a single integer or floating point value for single-band modes, and a tuple for multi-band modes (one value - per band). When creating RGB images, you can also use color + per band). When creating RGB or HSV images, you can also use color strings as supported by the ImageColor module. If the color is None, the image is not initialised. :returns: An :py:class:`~PIL.Image.Image` object. """ + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + _check_size(size) if color is None: @@ -2696,25 +3117,37 @@ def new(mode, size, color=0): color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name="raw", *args): +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. In its simplest form, this function takes three arguments (mode, size, and unpacked pixel data). - You can also use any pixel decoder supported by PIL. For more + You can also use any pixel decoder supported by PIL. For more information on available decoders, see the section - :ref:`Writing Your Own File Decoder `. + :ref:`Writing Your Own File Codec `. Note that this function decodes pixel data only, not entire images. If you have an entire image in a string, wrap it in a @@ -2731,19 +3164,27 @@ def frombytes(mode, size, data, decoder_name="raw", *args): _check_size(size) - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + im = new(mode, size) + if im.width != 0 and im.height != 0: + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] - if decoder_name == "raw" and args == (): - args = mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode - im = new(mode, size) - im.frombytes(data, decoder_name, args) + im.frombytes(data, decoder_name, decoder_args) return im -def frombuffer(mode, size, data, decoder_name="raw", *args): +def frombuffer( + mode: str, + size: tuple[int, int], + data: bytes | SupportsArrayInterface, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -2756,11 +3197,10 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): If you have an entire image file in a string, wrap it in a :py:class:`~io.BytesIO` object, and use :py:func:`~PIL.Image.open` to load it. - In the current version, the default parameters used for the "raw" decoder - differs from that used for :py:func:`~PIL.Image.frombytes`. This is a - bug, and will probably be fixed in a future release. The current release - issues a warning if you do this; to disable the warning, you should provide - the full set of parameters. See below for details. + The default parameters used for the "raw" decoder differs from that used for + :py:func:`~PIL.Image.frombytes`. This is a bug, and will probably be fixed in a + future release. The current release issues a warning if you do this; to disable + the warning, you should provide the full set of parameters. See below for details. :param mode: The image mode. See: :ref:`concept-modes`. :param size: The image size. @@ -2788,36 +3228,71 @@ def frombuffer(mode, size, data, decoder_name="raw", *args): if args == (): args = mode, 0, 1 if args[0] in _MAPMODES: - im = new(mode, (1, 1)) + im = new(mode, (0, 0)) im = im._new(core.map_buffer(data, size, decoder_name, 0, args)) + if mode == "P": + from . import ImagePalette + + im.palette = ImagePalette.ImagePalette("RGB", im.im.getpalette("RGB")) im.readonly = 1 return im return frombytes(mode, size, data, decoder_name, args) -def fromarray(obj, mode=None): +class SupportsArrayInterface(Protocol): + """ + An object that has an ``__array_interface__`` dictionary. + """ + + @property + def __array_interface__(self) -> dict[str, Any]: + raise NotImplementedError() + + +def fromarray(obj: SupportsArrayInterface, mode: str | None = None) -> Image: """ Creates an image memory from an object exporting the array interface - (using the buffer protocol). + (using the buffer protocol):: + + from PIL import Image + import numpy as np + a = np.zeros((5, 5)) + im = Image.fromarray(a) If ``obj`` is not contiguous, then the ``tobytes`` method is called and :py:func:`~PIL.Image.frombuffer` is used. - If you have an image in NumPy:: + In the case of NumPy, be aware that Pillow modes do not always correspond + to NumPy dtypes. Pillow modes only offer 1-bit pixels, 8-bit pixels, + 32-bit signed integer pixels, and 32-bit floating point pixels. + + Pillow images can also be converted to arrays:: from PIL import Image import numpy as np - im = Image.open('hopper.jpg') + im = Image.open("hopper.jpg") a = np.asarray(im) - Then this can be used to convert it to a Pillow image:: - - im = Image.fromarray(a) + When converting Pillow images to arrays however, only pixel values are + transferred. This means that P and PA mode images will lose their palette. :param obj: Object with array interface - :param mode: Mode to use (will be determined from type if None) - See: :ref:`concept-modes`. + :param mode: Optional mode to use when reading ``obj``. Will be determined from + type if ``None``. + + This will not be used to convert the data after reading, but will be used to + change how the data is read:: + + from PIL import Image + import numpy as np + a = np.full((1, 1), 300) + im = Image.fromarray(a, mode="L") + im.getpixel((0, 0)) # 44 + im = Image.fromarray(a, mode="RGB") + im.getpixel((0, 0)) # (44, 1, 0) + + See: :ref:`concept-modes` for general information about modes. :returns: An image object. .. versionadded:: 1.1.6 @@ -2830,11 +3305,14 @@ def fromarray(obj, mode=None): try: typekey = (1, 1) + shape[2:], arr["typestr"] except KeyError as e: - raise TypeError("Cannot handle this data type") from e + msg = "Cannot handle this data type" + raise TypeError(msg) from e try: mode, rawmode = _fromarray_typemap[typekey] except KeyError as e: - raise TypeError("Cannot handle this data type: %s, %s" % typekey) from e + typekey_shape, typestr = typekey + msg = f"Cannot handle this data type: {typekey_shape}, {typestr}" + raise TypeError(msg) from e else: rawmode = mode if mode in ["1", "L", "I", "P", "F"]: @@ -2844,33 +3322,39 @@ def fromarray(obj, mode=None): else: ndmax = 4 if ndim > ndmax: - raise ValueError(f"Too many dimensions: {ndim} > {ndmax}.") + msg = f"Too many dimensions: {ndim} > {ndmax}." + raise ValueError(msg) size = 1 if ndim == 1 else shape[1], shape[0] if strides is not None: if hasattr(obj, "tobytes"): obj = obj.tobytes() - else: + elif hasattr(obj, "tostring"): obj = obj.tostring() + else: + msg = "'strides' requires either tobytes() or tostring()" + raise ValueError(msg) return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) -def fromqimage(im): +def fromqimage(im: ImageQt.QImage) -> ImageFile.ImageFile: """Creates an image instance from a QImage image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqimage(im) -def fromqpixmap(im): +def fromqpixmap(im: ImageQt.QPixmap) -> ImageFile.ImageFile: """Creates an image instance from a QPixmap image""" from . import ImageQt if not ImageQt.qt_is_installed: - raise ImportError("Qt bindings are not installed") + msg = "Qt bindings are not installed" + raise ImportError(msg) return ImageQt.fromqpixmap(im) @@ -2895,24 +3379,24 @@ def fromqpixmap(im): ((1, 1, 2), "|u1"): ("LA", "LA"), ((1, 1, 3), "|u1"): ("RGB", "RGB"), ((1, 1, 4), "|u1"): ("RGBA", "RGBA"), + # shortcuts: + ((1, 1), f"{_ENDIAN}i4"): ("I", "I"), + ((1, 1), f"{_ENDIAN}f4"): ("F", "F"), } -# shortcuts -_fromarray_typemap[((1, 1), _ENDIAN + "i4")] = ("I", "I") -_fromarray_typemap[((1, 1), _ENDIAN + "f4")] = ("F", "F") - -def _decompression_bomb_check(size): +def _decompression_bomb_check(size: tuple[int, int]) -> None: if MAX_IMAGE_PIXELS is None: return - pixels = size[0] * size[1] + pixels = max(1, size[0]) * max(1, size[1]) if pixels > 2 * MAX_IMAGE_PIXELS: - raise DecompressionBombError( + msg = ( f"Image size ({pixels} pixels) exceeds limit of {2 * MAX_IMAGE_PIXELS} " "pixels, could be decompression bomb DOS attack." ) + raise DecompressionBombError(msg) if pixels > MAX_IMAGE_PIXELS: warnings.warn( @@ -2922,7 +3406,11 @@ def _decompression_bomb_check(size): ) -def open(fp, mode="r", formats=None): +def open( + fp: StrOrBytesPath | IO[bytes], + mode: Literal["r"] = "r", + formats: list[str] | tuple[str, ...] | None = None, +) -> ImageFile.ImageFile: """ Opens and identifies the given image file. @@ -2932,10 +3420,11 @@ def open(fp, mode="r", formats=None): :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filename (string), pathlib.Path object or a file object. + :param fp: A filename (string), os.PathLike object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, - and be opened in binary mode. + and be opened in binary mode. The file object will also seek to zero + before reading. :param mode: The mode. If given, this argument must be "r". :param formats: A list or tuple of formats to attempt to load the file in. This can be used to restrict the set of formats checked. @@ -2952,28 +3441,31 @@ def open(fp, mode="r", formats=None): """ if mode != "r": - raise ValueError(f"bad mode {repr(mode)}") + msg = f"bad mode {repr(mode)}" # type: ignore[unreachable] + raise ValueError(msg) elif isinstance(fp, io.StringIO): - raise ValueError( + msg = ( # type: ignore[unreachable] "StringIO cannot be used to open an image. " "Binary data must be used instead." ) + raise ValueError(msg) if formats is None: formats = ID elif not isinstance(formats, (list, tuple)): - raise TypeError("formats must be a list or tuple") + msg = "formats must be a list or tuple" # type: ignore[unreachable] + raise TypeError(msg) exclusive_fp = False - filename = "" - if isinstance(fp, Path): - filename = str(fp.resolve()) - elif isPath(fp): - filename = fp + filename: str | bytes = "" + if is_path(fp): + filename = os.fspath(fp) if filename: fp = builtins.open(filename, "rb") exclusive_fp = True + else: + fp = cast(IO[bytes], fp) try: fp.seek(0) @@ -2985,9 +3477,14 @@ def open(fp, mode="r", formats=None): preinit() - accept_warnings = [] + warning_messages: list[str] = [] - def _open_core(fp, filename, prefix, formats): + def _open_core( + fp: IO[bytes], + filename: str | bytes, + prefix: bytes, + formats: list[str] | tuple[str, ...], + ) -> ImageFile.ImageFile | None: for i in formats: i = i.upper() if i not in OPEN: @@ -2995,18 +3492,16 @@ def _open_core(fp, filename, prefix, formats): try: factory, accept = OPEN[i] result = not accept or accept(prefix) - if type(result) in [str, bytes]: - accept_warnings.append(result) + if isinstance(result, str): + warning_messages.append(result) elif result: fp.seek(0) im = factory(fp, filename) _decompression_bomb_check(im.size) return im - except (SyntaxError, IndexError, TypeError, struct.error): - # Leave disabled by default, spams the logs with image - # opening failures that are entirely expected. - # logger.debug("", exc_info=True) - continue + except (SyntaxError, IndexError, TypeError, struct.error) as e: + if WARN_POSSIBLE_FORMATS: + warning_messages.append(i + " opening failed. " + str(e)) except BaseException: if exclusive_fp: fp.close() @@ -3015,9 +3510,15 @@ def _open_core(fp, filename, prefix, formats): im = _open_core(fp, filename, prefix, formats) - if im is None: + if im is None and formats is ID: + checked_formats = ID.copy() if init(): - im = _open_core(fp, filename, prefix, formats) + im = _open_core( + fp, + filename, + prefix, + tuple(format for format in formats if format not in checked_formats), + ) if im: im._exclusive_fp = exclusive_fp @@ -3025,18 +3526,17 @@ def _open_core(fp, filename, prefix, formats): if exclusive_fp: fp.close() - for message in accept_warnings: + for message in warning_messages: warnings.warn(message) - raise UnidentifiedImageError( - "cannot identify image file %r" % (filename if filename else fp) - ) + msg = "cannot identify image file %r" % (filename if filename else fp) + raise UnidentifiedImageError(msg) # # Image processing. -def alpha_composite(im1, im2): +def alpha_composite(im1: Image, im2: Image) -> Image: """ Alpha composite im2 over im1. @@ -3051,10 +3551,10 @@ def alpha_composite(im1, im2): return im1._new(core.alpha_composite(im1.im, im2.im)) -def blend(im1, im2, alpha): +def blend(im1: Image, im2: Image, alpha: float) -> Image: """ Creates a new image by interpolating between two input images, using - a constant alpha.:: + a constant alpha:: out = image1 * (1.0 - alpha) + image2 * alpha @@ -3074,7 +3574,7 @@ def blend(im1, im2, alpha): return im1._new(core.blend(im1.im, im2.im, alpha)) -def composite(image1, image2, mask): +def composite(image1: Image, image2: Image, mask: Image) -> Image: """ Create composite image by blending images using a transparency mask. @@ -3091,7 +3591,7 @@ def composite(image1, image2, mask): return image -def eval(image, *args): +def eval(image: Image, *args: Callable[[int], float]) -> Image: """ Applies the function (which should take one argument) to each pixel in the given image. If the image has more than one band, the same @@ -3107,7 +3607,7 @@ def eval(image, *args): return image.point(args[0]) -def merge(mode, bands): +def merge(mode: str, bands: Sequence[Image]) -> Image: """ Merge a set of single band images into a new multiband image. @@ -3120,12 +3620,15 @@ def merge(mode, bands): """ if getmodebands(mode) != len(bands) or "*" in mode: - raise ValueError("wrong number of bands") + msg = "wrong number of bands" + raise ValueError(msg) for band in bands[1:]: if band.mode != getmodetype(mode): - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if band.size != bands[0].size: - raise ValueError("size mismatch") + msg = "size mismatch" + raise ValueError(msg) for band in bands: band.load() return bands[0]._new(core.merge(mode, *[b.im for b in bands])) @@ -3135,7 +3638,14 @@ def merge(mode, bands): # Plugin registry -def register_open(id, factory, accept=None): +def register_open( + id: str, + factory: ( + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile] + | type[ImageFile.ImageFile] + ), + accept: Callable[[bytes], bool | str] | None = None, +) -> None: """ Register an image file plugin. This function should not be used in application code. @@ -3146,14 +3656,19 @@ def register_open(id, factory, accept=None): reject images having another format. """ id = id.upper() - ID.append(id) + if id not in ID: + ID.append(id) OPEN[id] = factory, accept -def register_mime(id, mimetype): +def register_mime(id: str, mimetype: str) -> None: """ - Registers an image MIME type. This function should not be used - in application code. + Registers an image MIME type by populating ``Image.MIME``. This function + should not be used in application code. + + ``Image.MIME`` provides a mapping from image format identifiers to mime + formats, but :py:meth:`~PIL.ImageFile.ImageFile.get_format_mimetype` can + provide a different result for specific images. :param id: An image format identifier. :param mimetype: The image MIME type for this format. @@ -3161,7 +3676,9 @@ def register_mime(id, mimetype): MIME[id.upper()] = mimetype -def register_save(id, driver): +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3172,7 +3689,9 @@ def register_save(id, driver): SAVE[id.upper()] = driver -def register_save_all(id, driver): +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image function to save all the frames of a multiframe format. This function should not be @@ -3184,7 +3703,7 @@ def register_save_all(id, driver): SAVE_ALL[id.upper()] = driver -def register_extension(id, extension): +def register_extension(id: str, extension: str) -> None: """ Registers an image extension. This function should not be used in application code. @@ -3195,7 +3714,7 @@ def register_extension(id, extension): EXTENSION[extension.lower()] = id.upper() -def register_extensions(id, extensions): +def register_extensions(id: str, extensions: list[str]) -> None: """ Registers image extensions. This function should not be used in application code. @@ -3207,38 +3726,35 @@ def register_extensions(id, extensions): register_extension(id, extension) -def registered_extensions(): +def registered_extensions() -> dict[str, str]: """ Returns a dictionary containing all file extensions belonging to registered plugins """ - if not EXTENSION: - init() + init() return EXTENSION -def register_decoder(name, decoder): +def register_decoder(name: str, decoder: type[ImageFile.PyDecoder]) -> None: """ Registers an image decoder. This function should not be used in application code. :param name: The name of the decoder - :param decoder: A callable(mode, args) that returns an - ImageFile.PyDecoder object + :param decoder: An ImageFile.PyDecoder object .. versionadded:: 4.1.0 """ DECODERS[name] = decoder -def register_encoder(name, encoder): +def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: """ Registers an image encoder. This function should not be used in application code. :param name: The name of the encoder - :param encoder: A callable(mode, args) that returns an - ImageFile.PyEncoder object + :param encoder: An ImageFile.PyEncoder object .. versionadded:: 4.1.0 """ @@ -3249,43 +3765,32 @@ def register_encoder(name, encoder): # Simple display support. -def _show(image, **options): - options["_internal_pillow"] = True - _showxv(image, **options) - - -def _showxv(image, title=None, **options): +def _show(image: Image, **options: Any) -> None: from . import ImageShow - if "_internal_pillow" in options: - del options["_internal_pillow"] - else: - warnings.warn( - "_showxv is deprecated and will be removed in Pillow 9 (2022-01-02). " - "Use Image.show instead.", - DeprecationWarning, - ) - ImageShow.show(image, title, **options) + ImageShow.show(image, **options) # -------------------------------------------------------------------- # Effects -def effect_mandelbrot(size, extent, quality): +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int +) -> Image: """ Generate a Mandelbrot set covering the given extent. :param size: The requested size in pixels, as a 2-tuple: (width, height). :param extent: The extent to cover, as a 4-tuple: - (x0, y0, x1, y2). + (x0, y0, x1, y1). :param quality: Quality. """ return Image()._new(core.effect_mandelbrot(size, extent, quality)) -def effect_noise(size, sigma): +def effect_noise(size: tuple[int, int], sigma: float) -> Image: """ Generate Gaussian noise centered around 128. @@ -3296,7 +3801,7 @@ def effect_noise(size, sigma): return Image()._new(core.effect_noise(size, sigma)) -def linear_gradient(mode): +def linear_gradient(mode: str) -> Image: """ Generate 256x256 linear gradient from black to white, top to bottom. @@ -3305,7 +3810,7 @@ def linear_gradient(mode): return Image()._new(core.linear_gradient(mode)) -def radial_gradient(mode): +def radial_gradient(mode: str) -> Image: """ Generate 256x256 radial gradient from black to white, centre to edge. @@ -3318,19 +3823,18 @@ def radial_gradient(mode): # Resources -def _apply_env_variables(env=None): - if env is None: - env = os.environ +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ for var_name, setter in [ ("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCKS_MAX", core.set_blocks_max), ]: - if var_name not in env: + if var_name not in env_dict: continue - var = env[var_name].lower() + var = env_dict[var_name].lower() units = 1 for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: @@ -3339,13 +3843,13 @@ def _apply_env_variables(env=None): var = var[: -len(postfix)] try: - var = int(var) * units + var_int = int(var) * units except ValueError: warnings.warn(f"{var_name} is not int") continue try: - setter(var) + setter(var_int) except ValueError as e: warnings.warn(f"{var_name}: {e}") @@ -3354,16 +3858,58 @@ def _apply_env_variables(env=None): atexit.register(core.clear_cache) -class Exif(MutableMapping): - endian = None +if TYPE_CHECKING: + _ExifBase = MutableMapping[int, Any] +else: + _ExifBase = MutableMapping + - def __init__(self): - self._data = {} - self._ifds = {} - self._info = None - self._loaded_exif = None +class Exif(_ExifBase): + """ + This class provides read and write access to EXIF image data:: + + from PIL import Image + im = Image.open("exif.png") + exif = im.getexif() # Returns an instance of this class + + Information can be read and written, iterated over or deleted:: + + print(exif[274]) # 1 + exif[274] = 2 + for k, v in exif.items(): + print("Tag", k, "Value", v) # Tag 274 Value 2 + del exif[274] - def _fixup(self, value): + To access information beyond IFD0, :py:meth:`~PIL.Image.Exif.get_ifd` + returns a dictionary:: + + from PIL import ExifTags + im = Image.open("exif_gps.jpg") + exif = im.getexif() + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + print(gps_ifd) + + Other IFDs include ``ExifTags.IFD.Exif``, ``ExifTags.IFD.MakerNote``, + ``ExifTags.IFD.Interop`` and ``ExifTags.IFD.IFD1``. + + :py:mod:`~PIL.ExifTags` also has enum classes to provide names for data:: + + print(exif[ExifTags.Base.Software]) # PIL + print(gps_ifd[ExifTags.GPS.GPSDateStamp]) # 1999:99:99 99:99:99 + """ + + endian: str | None = None + bigtiff = False + _loaded = False + + def __init__(self) -> None: + self._data: dict[int, Any] = {} + self._hidden_data: dict[int, Any] = {} + self._ifds: dict[int, dict[int, Any]] = {} + self._info: TiffImagePlugin.ImageFileDirectory_v2 | None = None + self._loaded_exif: bytes | None = None + + def _fixup(self, value: Any) -> Any: try: if len(value) == 1 and isinstance(value, tuple): return value[0] @@ -3371,32 +3917,39 @@ def _fixup(self, value): pass return value - def _fixup_dict(self, src_dict): + def _fixup_dict(self, src_dict: dict[int, Any]) -> dict[int, Any]: # Helper function # returns a dict with any single item tuples/lists as individual values return {k: self._fixup(v) for k, v in src_dict.items()} - def _get_ifd_dict(self, offset): + def _get_ifd_dict( + self, offset: int, group: int | None = None + ) -> dict[int, Any] | None: try: # an offset pointer to the location of the nested embedded IFD. # It should be a long, but may be corrupted. self.fp.seek(offset) except (KeyError, TypeError): - pass + return None else: from . import TiffImagePlugin - info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + info = TiffImagePlugin.ImageFileDirectory_v2(self.head, group=group) info.load(self.fp) - return self._fixup_dict(info) + return self._fixup_dict(dict(info)) - def _get_head(self): + def _get_head(self) -> bytes: + version = b"\x2B" if self.bigtiff else b"\x2A" if self.endian == "<": - return b"II\x2A\x00\x08\x00\x00\x00" + head = b"II" + version + b"\x00" + o32le(8) else: - return b"MM\x00\x2A\x00\x00\x00\x08" + head = b"MM\x00" + version + o32be(8) + if self.bigtiff: + head += o32le(8) if self.endian == "<" else o32be(8) + head += b"\x00\x00\x00\x00" + return head - def load(self, data): + def load(self, data: bytes) -> None: # Extract EXIF information. This is highly experimental, # and is likely to be replaced with something better in a future # version. @@ -3407,14 +3960,15 @@ def load(self, data): return self._loaded_exif = data self._data.clear() + self._hidden_data.clear() self._ifds.clear() + while data and data.startswith(b"Exif\x00\x00"): + data = data[6:] if not data: self._info = None return - if data.startswith(b"Exif\x00\x00"): - data = data[6:] - self.fp = io.BytesIO(data) + self.fp: IO[bytes] = io.BytesIO(data) self.head = self.fp.read(8) # process dictionary from . import TiffImagePlugin @@ -3424,9 +3978,10 @@ def load(self, data): self.fp.seek(self._info.next) self._info.load(self.fp) - def load_from_fp(self, fp, offset=None): + def load_from_fp(self, fp: IO[bytes], offset: int | None = None) -> None: self._loaded_exif = None self._data.clear() + self._hidden_data.clear() self._ifds.clear() # process dictionary @@ -3442,55 +3997,69 @@ def load_from_fp(self, fp, offset=None): self.endian = self._info._endian if offset is None: offset = self._info.next + self.fp.tell() self.fp.seek(offset) self._info.load(self.fp) - def _get_merged_dict(self): + def _get_merged_dict(self) -> dict[int, Any]: merged_dict = dict(self) # get EXIF extension - if 0x8769 in self: - ifd = self._get_ifd_dict(self[0x8769]) + if ExifTags.IFD.Exif in self: + ifd = self._get_ifd_dict(self[ExifTags.IFD.Exif], ExifTags.IFD.Exif) if ifd: merged_dict.update(ifd) # GPS - if 0x8825 in self: - merged_dict[0x8825] = self._get_ifd_dict(self[0x8825]) + if ExifTags.IFD.GPSInfo in self: + merged_dict[ExifTags.IFD.GPSInfo] = self._get_ifd_dict( + self[ExifTags.IFD.GPSInfo], ExifTags.IFD.GPSInfo + ) return merged_dict - def tobytes(self, offset=8): + def tobytes(self, offset: int = 8) -> bytes: from . import TiffImagePlugin head = self._get_head() ifd = TiffImagePlugin.ImageFileDirectory_v2(ifh=head) + for tag, ifd_dict in self._ifds.items(): + if tag not in self: + ifd[tag] = ifd_dict for tag, value in self.items(): - if tag in [0x8769, 0x8225, 0x8825] and not isinstance(value, dict): + if tag in [ + ExifTags.IFD.Exif, + ExifTags.IFD.GPSInfo, + ] and not isinstance(value, dict): value = self.get_ifd(tag) if ( - tag == 0x8769 - and 0xA005 in value - and not isinstance(value[0xA005], dict) + tag == ExifTags.IFD.Exif + and ExifTags.IFD.Interop in value + and not isinstance(value[ExifTags.IFD.Interop], dict) ): value = value.copy() - value[0xA005] = self.get_ifd(0xA005) + value[ExifTags.IFD.Interop] = self.get_ifd(ExifTags.IFD.Interop) ifd[tag] = value return b"Exif\x00\x00" + head + ifd.tobytes(offset) - def get_ifd(self, tag): + def get_ifd(self, tag: int) -> dict[int, Any]: if tag not in self._ifds: - if tag in [0x8769, 0x8825]: - # exif, gpsinfo - if tag in self: - self._ifds[tag] = self._get_ifd_dict(self[tag]) - elif tag in [0xA005, 0x927C]: - # interop, makernote - if 0x8769 not in self._ifds: - self.get_ifd(0x8769) - tag_data = self._ifds[0x8769][tag] - if tag == 0x927C: - # makernote + if tag == ExifTags.IFD.IFD1: + if self._info is not None and self._info.next != 0: + ifd = self._get_ifd_dict(self._info.next) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: + offset = self._hidden_data.get(tag, self.get(tag)) + if offset is not None: + ifd = self._get_ifd_dict(offset, tag) + if ifd is not None: + self._ifds[tag] = ifd + elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.MakerNote]: + if ExifTags.IFD.Exif not in self._ifds: + self.get_ifd(ExifTags.IFD.Exif) + tag_data = self._ifds[ExifTags.IFD.Exif][tag] + if tag == ExifTags.IFD.MakerNote: from .TiffImagePlugin import ImageFileDirectory_v2 if tag_data[:8] == b"FUJIFILM": @@ -3542,7 +4111,9 @@ def get_ifd(self, tag): (offset,) = struct.unpack(">L", data) self.fp.seek(offset) - camerainfo = {"ModelID": self.fp.read(4)} + camerainfo: dict[str, int | bytes] = { + "ModelID": self.fp.read(4) + } self.fp.read(4) # Seconds since 2000 @@ -3558,53 +4129,68 @@ def get_ifd(self, tag): ][1] camerainfo["Parallax"] = handler( ImageFileDirectory_v2(), parallax, False - ) + )[0] self.fp.read(4) camerainfo["Category"] = self.fp.read(2) - makernote = {0x1101: dict(self._fixup_dict(camerainfo))} + makernote = {0x1101: camerainfo} self._ifds[tag] = makernote else: - # interop - self._ifds[tag] = self._get_ifd_dict(tag_data) - return self._ifds.get(tag, {}) - - def __str__(self): + # Interop + ifd = self._get_ifd_dict(tag_data, tag) + if ifd is not None: + self._ifds[tag] = ifd + ifd = self._ifds.setdefault(tag, {}) + if tag == ExifTags.IFD.Exif and self._hidden_data: + ifd = { + k: v + for (k, v) in ifd.items() + if k not in (ExifTags.IFD.Interop, ExifTags.IFD.MakerNote) + } + return ifd + + def hide_offsets(self) -> None: + for tag in (ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo): + if tag in self: + self._hidden_data[tag] = self[tag] + del self[tag] + + def __str__(self) -> str: if self._info is not None: # Load all keys into self._data - for tag in self._info.keys(): + for tag in self._info: self[tag] return str(self._data) - def __len__(self): + def __len__(self) -> int: keys = set(self._data) if self._info is not None: keys.update(self._info) return len(keys) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if self._info is not None and tag not in self._data and tag in self._info: self._data[tag] = self._fixup(self._info[tag]) del self._info[tag] return self._data[tag] - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._data or (self._info is not None and tag in self._info) - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: if self._info is not None and tag in self._info: del self._info[tag] self._data[tag] = value - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: if self._info is not None and tag in self._info: del self._info[tag] else: del self._data[tag] - def __iter__(self): + def __iter__(self) -> Iterator[int]: keys = set(self._data) if self._info is not None: keys.update(self._info) diff --git a/dependencies/WIN/PIL/ImageChops.py b/dependencies/WIN/PIL/ImageChops.py index 61d3a29..29a5c99 100644 --- a/dependencies/WIN/PIL/ImageChops.py +++ b/dependencies/WIN/PIL/ImageChops.py @@ -15,11 +15,13 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + from . import Image -def constant(image, value): - """Fill a channel with a given grey level. +def constant(image: Image.Image, value: int) -> Image.Image: + """Fill a channel with a given gray level. :rtype: :py:class:`~PIL.Image.Image` """ @@ -27,7 +29,7 @@ def constant(image, value): return Image.new("L", image.size, value) -def duplicate(image): +def duplicate(image: Image.Image) -> Image.Image: """Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`. :rtype: :py:class:`~PIL.Image.Image` @@ -36,11 +38,9 @@ def duplicate(image): return image.copy() -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ - Invert an image (channel). - - .. code-block:: python + Invert an image (channel). :: out = MAX - image @@ -51,12 +51,10 @@ def invert(image): return image._new(image.im.chop_invert()) -def lighter(image1, image2): +def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the lighter values. - - .. code-block:: python + the lighter values. :: out = max(image1, image2) @@ -68,12 +66,10 @@ def lighter(image1, image2): return image1._new(image1.im.chop_lighter(image2.im)) -def darker(image1, image2): +def darker(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Compares the two images, pixel by pixel, and returns a new image containing - the darker values. - - .. code-block:: python + the darker values. :: out = min(image1, image2) @@ -85,12 +81,10 @@ def darker(image1, image2): return image1._new(image1.im.chop_darker(image2.im)) -def difference(image1, image2): +def difference(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Returns the absolute value of the pixel-by-pixel difference between the two - images. - - .. code-block:: python + images. :: out = abs(image1 - image2) @@ -102,14 +96,12 @@ def difference(image1, image2): return image1._new(image1.im.chop_difference(image2.im)) -def multiply(image1, image2): +def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other. If you multiply an image with a solid black image, the result is black. If - you multiply with a solid white image, the image is unaffected. - - .. code-block:: python + you multiply with a solid white image, the image is unaffected. :: out = image1 * image2 / MAX @@ -121,11 +113,9 @@ def multiply(image1, image2): return image1._new(image1.im.chop_multiply(image2.im)) -def screen(image1, image2): +def screen(image1: Image.Image, image2: Image.Image) -> Image.Image: """ - Superimposes two inverted images on top of each other. - - .. code-block:: python + Superimposes two inverted images on top of each other. :: out = MAX - ((MAX - image1) * (MAX - image2) / MAX) @@ -137,7 +127,7 @@ def screen(image1, image2): return image1._new(image1.im.chop_screen(image2.im)) -def soft_light(image1, image2): +def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Soft Light algorithm @@ -149,7 +139,7 @@ def soft_light(image1, image2): return image1._new(image1.im.chop_soft_light(image2.im)) -def hard_light(image1, image2): +def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Hard Light algorithm @@ -161,7 +151,7 @@ def hard_light(image1, image2): return image1._new(image1.im.chop_hard_light(image2.im)) -def overlay(image1, image2): +def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image: """ Superimposes two images on top of each other using the Overlay algorithm @@ -173,12 +163,12 @@ def overlay(image1, image2): return image1._new(image1.im.chop_overlay(image2.im)) -def add(image1, image2, scale=1.0, offset=0): +def add( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Adds two images, dividing the result by scale and adding the - offset. If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + offset. If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 + image2) / scale + offset) @@ -190,12 +180,12 @@ def add(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_add(image2.im, scale, offset)) -def subtract(image1, image2, scale=1.0, offset=0): +def subtract( + image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0 +) -> Image.Image: """ Subtracts two images, dividing the result by scale and adding the offset. - If omitted, scale defaults to 1.0, and offset to 0.0. - - .. code-block:: python + If omitted, scale defaults to 1.0, and offset to 0.0. :: out = ((image1 - image2) / scale + offset) @@ -207,10 +197,8 @@ def subtract(image1, image2, scale=1.0, offset=0): return image1._new(image1.im.chop_subtract(image2.im, scale, offset)) -def add_modulo(image1, image2): - """Add two images, without clipping the result. - - .. code-block:: python +def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Add two images, without clipping the result. :: out = ((image1 + image2) % MAX) @@ -222,10 +210,8 @@ def add_modulo(image1, image2): return image1._new(image1.im.chop_add_modulo(image2.im)) -def subtract_modulo(image1, image2): - """Subtract two images, without clipping the result. - - .. code-block:: python +def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image: + """Subtract two images, without clipping the result. :: out = ((image1 - image2) % MAX) @@ -237,15 +223,13 @@ def subtract_modulo(image1, image2): return image1._new(image1.im.chop_subtract_modulo(image2.im)) -def logical_and(image1, image2): +def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical AND between two images. Both of the images must have mode "1". If you would like to perform a logical AND on an image with a mode other than "1", try :py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask - as the second image. - - .. code-block:: python + as the second image. :: out = ((image1 and image2) % MAX) @@ -257,12 +241,10 @@ def logical_and(image1, image2): return image1._new(image1.im.chop_and(image2.im)) -def logical_or(image1, image2): +def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical OR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((image1 or image2) % MAX) @@ -274,12 +256,10 @@ def logical_or(image1, image2): return image1._new(image1.im.chop_or(image2.im)) -def logical_xor(image1, image2): +def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image: """Logical XOR between two images. - Both of the images must have mode "1". - - .. code-block:: python + Both of the images must have mode "1". :: out = ((bool(image1) != bool(image2)) % MAX) @@ -291,7 +271,7 @@ def logical_xor(image1, image2): return image1._new(image1.im.chop_xor(image2.im)) -def blend(image1, image2, alpha): +def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image: """Blend images using constant transparency weight. Alias for :py:func:`PIL.Image.blend`. @@ -301,7 +281,9 @@ def blend(image1, image2, alpha): return Image.blend(image1, image2, alpha) -def composite(image1, image2, mask): +def composite( + image1: Image.Image, image2: Image.Image, mask: Image.Image +) -> Image.Image: """Create composite using transparency mask. Alias for :py:func:`PIL.Image.composite`. @@ -311,11 +293,12 @@ def composite(image1, image2, mask): return Image.composite(image1, image2, mask) -def offset(image, xoffset, yoffset=None): +def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image: """Returns a copy of the image where data has been offset by the given distances. Data wraps around the edges. If ``yoffset`` is omitted, it is assumed to be equal to ``xoffset``. + :param image: Input image. :param xoffset: The horizontal distance. :param yoffset: The vertical distance. If omitted, both distances are set to the same value. diff --git a/dependencies/WIN/PIL/ImageCms.py b/dependencies/WIN/PIL/ImageCms.py index 3699095..fdfbee7 100644 --- a/dependencies/WIN/PIL/ImageCms.py +++ b/dependencies/WIN/PIL/ImageCms.py @@ -4,6 +4,9 @@ # Optional color management support, based on Kevin Cazabon's PyCMS # library. +# Originally released under LGPL. Graciously donated to PIL in +# March 2009, for distribution under the standard PIL license + # History: # 2009-03-08 fl Added to PIL. @@ -14,29 +17,40 @@ # See the README file for information on usage and redistribution. See # below for the original description. +from __future__ import annotations +import operator import sys +from enum import IntEnum, IntFlag +from functools import reduce +from typing import Any, Literal, SupportsFloat, SupportsInt, Union -from PIL import Image +from . import Image, __version__ +from ._deprecate import deprecate +from ._typing import SupportsRead try: - from PIL import _imagingcms + from . import _imagingcms as core + + _CmsProfileCompatible = Union[ + str, SupportsRead[bytes], core.CmsProfile, "ImageCmsProfile" + ] except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. - from ._util import deferred_error + from ._util import DeferredError - _imagingcms = deferred_error(ex) + core = DeferredError.new(ex) -DESCRIPTION = """ +_DESCRIPTION = """ pyCMS a Python / PIL interface to the littleCMS ICC Color Management System Copyright (C) 2002-2003 Kevin Cazabon kevin@cazabon.com - http://www.cazabon.com + https://www.cazabon.com - pyCMS home page: http://www.cazabon.com/pyCMS + pyCMS home page: https://www.cazabon.com/pyCMS littleCMS home page: https://www.littlecms.com (littleCMS is Copyright (C) 1998-2001 Marti Maria) @@ -91,28 +105,110 @@ """ -VERSION = "1.0.0 pil" +_VERSION = "1.0.0 pil" + + +def __getattr__(name: str) -> Any: + if name == "DESCRIPTION": + deprecate("PIL.ImageCms.DESCRIPTION", 12) + return _DESCRIPTION + elif name == "VERSION": + deprecate("PIL.ImageCms.VERSION", 12) + return _VERSION + elif name == "FLAGS": + deprecate("PIL.ImageCms.FLAGS", 12, "PIL.ImageCms.Flags") + return _FLAGS + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) + # --------------------------------------------------------------------. -core = _imagingcms # # intent/direction values -INTENT_PERCEPTUAL = 0 -INTENT_RELATIVE_COLORIMETRIC = 1 -INTENT_SATURATION = 2 -INTENT_ABSOLUTE_COLORIMETRIC = 3 -DIRECTION_INPUT = 0 -DIRECTION_OUTPUT = 1 -DIRECTION_PROOF = 2 +class Intent(IntEnum): + PERCEPTUAL = 0 + RELATIVE_COLORIMETRIC = 1 + SATURATION = 2 + ABSOLUTE_COLORIMETRIC = 3 + + +class Direction(IntEnum): + INPUT = 0 + OUTPUT = 1 + PROOF = 2 + # # flags -FLAGS = { + +class Flags(IntFlag): + """Flags and documentation are taken from ``lcms2.h``.""" + + NONE = 0 + NOCACHE = 0x0040 + """Inhibit 1-pixel cache""" + NOOPTIMIZE = 0x0100 + """Inhibit optimizations""" + NULLTRANSFORM = 0x0200 + """Don't transform anyway""" + GAMUTCHECK = 0x1000 + """Out of Gamut alarm""" + SOFTPROOFING = 0x4000 + """Do softproofing""" + BLACKPOINTCOMPENSATION = 0x2000 + NOWHITEONWHITEFIXUP = 0x0004 + """Don't fix scum dot""" + HIGHRESPRECALC = 0x0400 + """Use more memory to give better accuracy""" + LOWRESPRECALC = 0x0800 + """Use less memory to minimize resources""" + # this should be 8BITS_DEVICELINK, but that is not a valid name in Python: + USE_8BITS_DEVICELINK = 0x0008 + """Create 8 bits devicelinks""" + GUESSDEVICECLASS = 0x0020 + """Guess device class (for ``transform2devicelink``)""" + KEEP_SEQUENCE = 0x0080 + """Keep profile sequence for devicelink creation""" + FORCE_CLUT = 0x0002 + """Force CLUT optimization""" + CLUT_POST_LINEARIZATION = 0x0001 + """create postlinearization tables if possible""" + CLUT_PRE_LINEARIZATION = 0x0010 + """create prelinearization tables if possible""" + NONEGATIVES = 0x8000 + """Prevent negative numbers in floating point transforms""" + COPY_ALPHA = 0x04000000 + """Alpha channels are copied on ``cmsDoTransform()``""" + NODEFAULTRESOURCEDEF = 0x01000000 + + _GRIDPOINTS_1 = 1 << 16 + _GRIDPOINTS_2 = 2 << 16 + _GRIDPOINTS_4 = 4 << 16 + _GRIDPOINTS_8 = 8 << 16 + _GRIDPOINTS_16 = 16 << 16 + _GRIDPOINTS_32 = 32 << 16 + _GRIDPOINTS_64 = 64 << 16 + _GRIDPOINTS_128 = 128 << 16 + + @staticmethod + def GRIDPOINTS(n: int) -> Flags: + """ + Fine-tune control over number of gridpoints + + :param n: :py:class:`int` in range ``0 <= n <= 255`` + """ + return Flags.NONE | ((n & 0xFF) << 16) + + +_MAX_FLAG = reduce(operator.or_, Flags) + + +_FLAGS = { "MATRIXINPUT": 1, "MATRIXOUTPUT": 2, "MATRIXONLY": (1 | 2), @@ -132,14 +228,9 @@ "SOFTPROOFING": 16384, # Do softproofing "PRESERVEBLACK": 32768, # Black preservation "NODEFAULTRESOURCEDEF": 16777216, # CRD special - "GRIDPOINTS": lambda n: ((n) & 0xFF) << 16, # Gridpoints + "GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints } -_MAX_FLAG = 0 -for flag in FLAGS.values(): - if isinstance(flag, int): - _MAX_FLAG = _MAX_FLAG | flag - # --------------------------------------------------------------------. # Experimental PIL-level API @@ -150,7 +241,7 @@ class ImageCmsProfile: - def __init__(self, profile): + def __init__(self, profile: str | SupportsRead[bytes] | core.CmsProfile) -> None: """ :param profile: Either a string representing a filename, a file like object containing a profile or a @@ -170,22 +261,19 @@ def __init__(self, profile): self._set(core.profile_open(profile), profile) elif hasattr(profile, "read"): self._set(core.profile_frombytes(profile.read())) - elif isinstance(profile, _imagingcms.CmsProfile): + elif isinstance(profile, core.CmsProfile): self._set(profile) else: - raise TypeError("Invalid type for Profile") + msg = "Invalid type for Profile" # type: ignore[unreachable] + raise TypeError(msg) - def _set(self, profile, filename=None): + def _set(self, profile: core.CmsProfile, filename: str | None = None) -> None: self.profile = profile self.filename = filename - if profile: - self.product_name = None # profile.product_name - self.product_info = None # profile.product_info - else: - self.product_name = None - self.product_info = None + self.product_name = None # profile.product_name + self.product_info = None # profile.product_info - def tobytes(self): + def tobytes(self) -> bytes: """ Returns the profile in a format suitable for embedding in saved images. @@ -197,7 +285,6 @@ def tobytes(self): class ImageCmsTransform(Image.ImagePointHandler): - """ Transform. This can be used with the procedural API, or with the standard :py:func:`~PIL.Image.Image.point` method. @@ -207,15 +294,40 @@ class ImageCmsTransform(Image.ImagePointHandler): def __init__( self, - input, - output, - input_mode, - output_mode, - intent=INTENT_PERCEPTUAL, - proof=None, - proof_intent=INTENT_ABSOLUTE_COLORIMETRIC, - flags=0, + input: ImageCmsProfile, + output: ImageCmsProfile, + input_mode: str, + output_mode: str, + intent: Intent = Intent.PERCEPTUAL, + proof: ImageCmsProfile | None = None, + proof_intent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.NONE, ): + supported_modes = ( + "RGB", + "RGBA", + "RGBX", + "CMYK", + "I;16", + "I;16L", + "I;16B", + "YCbCr", + "LAB", + "L", + "1", + ) + for mode in (input_mode, output_mode): + if mode not in supported_modes: + deprecate( + mode, + 12, + { + "L;16": "I;16 or I;16L", + "L:16B": "I;16B", + "YCCA": "YCbCr", + "YCC": "YCbCr", + }.get(mode), + ) if proof is None: self.transform = core.buildTransform( input.profile, output.profile, input_mode, output_mode, intent, flags @@ -237,27 +349,26 @@ def __init__( self.output_profile = output - def point(self, im): + def point(self, im: Image.Image) -> Image.Image: return self.apply(im) - def apply(self, im, imOut=None): - im.load() + def apply(self, im: Image.Image, imOut: Image.Image | None = None) -> Image.Image: if imOut is None: imOut = Image.new(self.output_mode, im.size, None) - self.transform.apply(im.im.id, imOut.im.id) + self.transform.apply(im.getim(), imOut.getim()) imOut.info["icc_profile"] = self.output_profile.tobytes() return imOut - def apply_in_place(self, im): - im.load() + def apply_in_place(self, im: Image.Image) -> Image.Image: if im.mode != self.output_mode: - raise ValueError("mode mismatch") # wrong output mode - self.transform.apply(im.im.id, im.im.id) + msg = "mode mismatch" + raise ValueError(msg) # wrong output mode + self.transform.apply(im.getim(), im.getim()) im.info["icc_profile"] = self.output_profile.tobytes() return im -def get_display_profile(handle=None): +def get_display_profile(handle: SupportsInt | None = None) -> ImageCmsProfile | None: """ (experimental) Fetches the profile for the current display device. @@ -267,12 +378,12 @@ def get_display_profile(handle=None): if sys.platform != "win32": return None - from PIL import ImageWin + from . import ImageWin # type: ignore[unused-ignore, unreachable] if isinstance(handle, ImageWin.HDC): - profile = core.get_display_profile_win32(handle, 1) + profile = core.get_display_profile_win32(int(handle), 1) else: - profile = core.get_display_profile_win32(handle or 0) + profile = core.get_display_profile_win32(int(handle or 0)) if profile is None: return None return ImageCmsProfile(profile) @@ -284,7 +395,6 @@ def get_display_profile(handle=None): class PyCMSError(Exception): - """(pyCMS) Exception class. This is used for all errors in the pyCMS API.""" @@ -292,14 +402,14 @@ class PyCMSError(Exception): def profileToProfile( - im, - inputProfile, - outputProfile, - renderingIntent=INTENT_PERCEPTUAL, - outputMode=None, - inPlace=False, - flags=0, -): + im: Image.Image, + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + renderingIntent: Intent = Intent.PERCEPTUAL, + outputMode: str | None = None, + inPlace: bool = False, + flags: Flags = Flags.NONE, +) -> Image.Image | None: """ (pyCMS) Applies an ICC transformation to a given image, mapping from ``inputProfile`` to ``outputProfile``. @@ -331,10 +441,10 @@ def profileToProfile( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -356,10 +466,12 @@ def profileToProfile( outputMode = im.mode if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -385,7 +497,9 @@ def profileToProfile( return imOut -def getOpenProfile(profileFilename): +def getOpenProfile( + profileFilename: str | SupportsRead[bytes] | core.CmsProfile, +) -> ImageCmsProfile: """ (pyCMS) Opens an ICC profile file. @@ -408,13 +522,13 @@ def getOpenProfile(profileFilename): def buildTransform( - inputProfile, - outputProfile, - inMode, - outMode, - renderingIntent=INTENT_PERCEPTUAL, - flags=0, -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + flags: Flags = Flags.NONE, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``. Use applyTransform to apply the transform to a given @@ -458,10 +572,10 @@ def buildTransform( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -471,10 +585,12 @@ def buildTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -489,15 +605,15 @@ def buildTransform( def buildProofTransform( - inputProfile, - outputProfile, - proofProfile, - inMode, - outMode, - renderingIntent=INTENT_PERCEPTUAL, - proofRenderingIntent=INTENT_ABSOLUTE_COLORIMETRIC, - flags=FLAGS["SOFTPROOFING"], -): + inputProfile: _CmsProfileCompatible, + outputProfile: _CmsProfileCompatible, + proofProfile: _CmsProfileCompatible, + inMode: str, + outMode: str, + renderingIntent: Intent = Intent.PERCEPTUAL, + proofRenderingIntent: Intent = Intent.ABSOLUTE_COLORIMETRIC, + flags: Flags = Flags.SOFTPROOFING, +) -> ImageCmsTransform: """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the ``outputProfile``, but tries to simulate the result that would be @@ -550,20 +666,20 @@ def buildProofTransform( :param renderingIntent: Integer (0-3) specifying the rendering intent you wish to use for the input->proof (simulated) transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. :param proofRenderingIntent: Integer (0-3) specifying the rendering intent you wish to use for proof->output transform - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -573,10 +689,12 @@ def buildProofTransform( """ if not isinstance(renderingIntent, int) or not (0 <= renderingIntent <= 3): - raise PyCMSError("renderingIntent must be an integer between 0 and 3") + msg = "renderingIntent must be an integer between 0 and 3" + raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - raise PyCMSError("flags must be an integer between 0 and %s" + _MAX_FLAG) + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" + raise PyCMSError(msg) try: if not isinstance(inputProfile, ImageCmsProfile): @@ -603,16 +721,18 @@ def buildProofTransform( buildProofTransformFromOpenProfiles = buildProofTransform -def applyTransform(im, transform, inPlace=False): +def applyTransform( + im: Image.Image, transform: ImageCmsTransform, inPlace: bool = False +) -> Image.Image | None: """ (pyCMS) Applies a transform to a given image. - If ``im.mode != transform.inMode``, a :exc:`PyCMSError` is raised. + If ``im.mode != transform.input_mode``, a :exc:`PyCMSError` is raised. - If ``inPlace`` is ``True`` and ``transform.inMode != transform.outMode``, a + If ``inPlace`` is ``True`` and ``transform.input_mode != transform.output_mode``, a :exc:`PyCMSError` is raised. - If ``im.mode``, ``transform.inMode`` or ``transform.outMode`` is not + If ``im.mode``, ``transform.input_mode`` or ``transform.output_mode`` is not supported by pyCMSdll or the profiles you used for the transform, a :exc:`PyCMSError` is raised. @@ -626,13 +746,13 @@ def applyTransform(im, transform, inPlace=False): If you want to modify im in-place instead of receiving a new image as the return value, set ``inPlace`` to ``True``. This can only be done if - ``transform.inMode`` and ``transform.outMode`` are the same, because we can't - change the mode in-place (the buffer sizes for some modes are + ``transform.input_mode`` and ``transform.output_mode`` are the same, because we + can't change the mode in-place (the buffer sizes for some modes are different). The default behavior is to return a new :py:class:`~PIL.Image.Image` - object of the same dimensions in mode ``transform.outMode``. + object of the same dimensions in mode ``transform.output_mode``. - :param im: An :py:class:`~PIL.Image.Image` object, and im.mode must be the same - as the ``inMode`` supported by the transform. + :param im: An :py:class:`~PIL.Image.Image` object, and ``im.mode`` must be the same + as the ``input_mode`` supported by the transform. :param transform: A valid CmsTransform class object :param inPlace: Bool. If ``True``, ``im`` is modified in place and ``None`` is returned, if ``False``, a new :py:class:`~PIL.Image.Image` object with the @@ -656,7 +776,9 @@ def applyTransform(im, transform, inPlace=False): return imOut -def createProfile(colorSpace, colorTemp=-1): +def createProfile( + colorSpace: Literal["LAB", "XYZ", "sRGB"], colorTemp: SupportsFloat = 0 +) -> core.CmsProfile: """ (pyCMS) Creates a profile. @@ -678,7 +800,7 @@ def createProfile(colorSpace, colorTemp=-1): :param colorSpace: String, the color space of the profile you wish to create. Currently only "LAB", "XYZ", and "sRGB" are supported. - :param colorTemp: Positive integer for the white point for the profile, in + :param colorTemp: Positive number for the white point for the profile, in degrees Kelvin (i.e. 5000, 6500, 9600, etc.). The default is for D50 illuminant if omitted (5000k). colorTemp is ONLY applied to LAB profiles, and is ignored for XYZ and sRGB. @@ -687,17 +809,17 @@ def createProfile(colorSpace, colorTemp=-1): """ if colorSpace not in ["LAB", "XYZ", "sRGB"]: - raise PyCMSError( + msg = ( f"Color space not supported for on-the-fly profile creation ({colorSpace})" ) + raise PyCMSError(msg) if colorSpace == "LAB": try: colorTemp = float(colorTemp) except (TypeError, ValueError) as e: - raise PyCMSError( - f'Color temperature must be numeric, "{colorTemp}" not valid' - ) from e + msg = f'Color temperature must be numeric, "{colorTemp}" not valid' + raise PyCMSError(msg) from e try: return core.createProfile(colorSpace, colorTemp) @@ -705,7 +827,7 @@ def createProfile(colorSpace, colorTemp=-1): raise PyCMSError(v) from v -def getProfileName(profile): +def getProfileName(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product name for the given profile. @@ -739,15 +861,15 @@ def getProfileName(profile): if not (model or manufacturer): return (profile.profile.profile_description or "") + "\n" - if not manufacturer or len(model) > 30: - return model + "\n" + if not manufacturer or (model and len(model) > 30): + return f"{model}\n" return f"{model} - {manufacturer}\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v -def getProfileInfo(profile): +def getProfileInfo(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the internal product information for the given profile. @@ -777,17 +899,14 @@ def getProfileInfo(profile): # info was description \r\n\r\n copyright \r\n\r\n K007 tag \r\n\r\n whitepoint description = profile.profile.profile_description cpright = profile.profile.copyright - arr = [] - for elt in (description, cpright): - if elt: - arr.append(elt) - return "\r\n\r\n".join(arr) + "\r\n\r\n" + elements = [element for element in (description, cpright) if element] + return "\r\n\r\n".join(elements) + "\r\n\r\n" except (AttributeError, OSError, TypeError, ValueError) as v: raise PyCMSError(v) from v -def getProfileCopyright(profile): +def getProfileCopyright(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the copyright for the given profile. @@ -815,7 +934,7 @@ def getProfileCopyright(profile): raise PyCMSError(v) from v -def getProfileManufacturer(profile): +def getProfileManufacturer(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the manufacturer for the given profile. @@ -843,7 +962,7 @@ def getProfileManufacturer(profile): raise PyCMSError(v) from v -def getProfileModel(profile): +def getProfileModel(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the model for the given profile. @@ -872,7 +991,7 @@ def getProfileModel(profile): raise PyCMSError(v) from v -def getProfileDescription(profile): +def getProfileDescription(profile: _CmsProfileCompatible) -> str: """ (pyCMS) Gets the description for the given profile. @@ -901,7 +1020,7 @@ def getProfileDescription(profile): raise PyCMSError(v) from v -def getDefaultIntent(profile): +def getDefaultIntent(profile: _CmsProfileCompatible) -> int: """ (pyCMS) Gets the default intent name for the given profile. @@ -922,10 +1041,10 @@ def getDefaultIntent(profile): :returns: Integer 0-3 specifying the default rendering intent for this profile. - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. @@ -940,7 +1059,9 @@ def getDefaultIntent(profile): raise PyCMSError(v) from v -def isIntentSupported(profile, intent, direction): +def isIntentSupported( + profile: _CmsProfileCompatible, intent: Intent, direction: Direction +) -> Literal[-1, 1]: """ (pyCMS) Checks if a given intent is supported. @@ -960,19 +1081,19 @@ def isIntentSupported(profile, intent, direction): :param intent: Integer (0-3) specifying the rendering intent you wish to use with this profile - ImageCms.INTENT_PERCEPTUAL = 0 (DEFAULT) - ImageCms.INTENT_RELATIVE_COLORIMETRIC = 1 - ImageCms.INTENT_SATURATION = 2 - ImageCms.INTENT_ABSOLUTE_COLORIMETRIC = 3 + ImageCms.Intent.PERCEPTUAL = 0 (DEFAULT) + ImageCms.Intent.RELATIVE_COLORIMETRIC = 1 + ImageCms.Intent.SATURATION = 2 + ImageCms.Intent.ABSOLUTE_COLORIMETRIC = 3 see the pyCMS documentation for details on rendering intents and what they do. :param direction: Integer specifying if the profile is to be used for input, output, or proof - INPUT = 0 (or use ImageCms.DIRECTION_INPUT) - OUTPUT = 1 (or use ImageCms.DIRECTION_OUTPUT) - PROOF = 2 (or use ImageCms.DIRECTION_PROOF) + INPUT = 0 (or use ImageCms.Direction.INPUT) + OUTPUT = 1 (or use ImageCms.Direction.OUTPUT) + PROOF = 2 (or use ImageCms.Direction.PROOF) :returns: 1 if the intent/direction are supported, -1 if they are not. :exception PyCMSError: @@ -991,9 +1112,14 @@ def isIntentSupported(profile, intent, direction): raise PyCMSError(v) from v -def versions(): +def versions() -> tuple[str, str | None, str, str]: """ (pyCMS) Fetches versions. """ - return (VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__) + deprecate( + "PIL.ImageCms.versions()", + 12, + '(PIL.features.version("littlecms2"), sys.version, PIL.__version__)', + ) + return _VERSION, core.littlecms_version, sys.version.split()[0], __version__ diff --git a/dependencies/WIN/PIL/ImageColor.py b/dependencies/WIN/PIL/ImageColor.py index 25f92f2..9a15a8e 100644 --- a/dependencies/WIN/PIL/ImageColor.py +++ b/dependencies/WIN/PIL/ImageColor.py @@ -16,13 +16,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from functools import lru_cache from . import Image -def getrgb(color): +@lru_cache +def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]: """ Convert a color string to an RGB or RGBA tuple. If the string cannot be parsed, this function raises a :py:exc:`ValueError` exception. @@ -33,19 +36,22 @@ def getrgb(color): :return: ``(red, green, blue[, alpha])`` """ if len(color) > 100: - raise ValueError("color specifier is too long") + msg = "color specifier is too long" + raise ValueError(msg) color = color.lower() rgb = colormap.get(color, None) if rgb: if isinstance(rgb, tuple): return rgb - colormap[color] = rgb = getrgb(rgb) - return rgb + rgb_tuple = getrgb(rgb) + assert len(rgb_tuple) == 3 + colormap[color] = rgb_tuple + return rgb_tuple # check for known string formats if re.match("#[a-f0-9]{3}$", color): - return (int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)) + return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16) if re.match("#[a-f0-9]{4}$", color): return ( @@ -56,7 +62,7 @@ def getrgb(color): ) if re.match("#[a-f0-9]{6}$", color): - return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) + return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16) if re.match("#[a-f0-9]{8}$", color): return ( @@ -68,7 +74,7 @@ def getrgb(color): m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + return int(m.group(1)), int(m.group(2)), int(m.group(3)) m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color) if m: @@ -84,15 +90,15 @@ def getrgb(color): if m: from colorsys import hls_to_rgb - rgb = hls_to_rgb( + rgb_floats = hls_to_rgb( float(m.group(1)) / 360.0, float(m.group(3)) / 100.0, float(m.group(2)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match( @@ -101,53 +107,65 @@ def getrgb(color): if m: from colorsys import hsv_to_rgb - rgb = hsv_to_rgb( + rgb_floats = hsv_to_rgb( float(m.group(1)) / 360.0, float(m.group(2)) / 100.0, float(m.group(3)) / 100.0, ) return ( - int(rgb[0] * 255 + 0.5), - int(rgb[1] * 255 + 0.5), - int(rgb[2] * 255 + 0.5), + int(rgb_floats[0] * 255 + 0.5), + int(rgb_floats[1] * 255 + 0.5), + int(rgb_floats[2] * 255 + 0.5), ) m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))) - raise ValueError(f"unknown color specifier: {repr(color)}") + return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) + msg = f"unknown color specifier: {repr(color)}" + raise ValueError(msg) -def getcolor(color, mode): +@lru_cache +def getcolor(color: str, mode: str) -> int | tuple[int, ...]: """ - Same as :py:func:`~PIL.ImageColor.getrgb`, but converts the RGB value to a - greyscale value if the mode is not color or a palette image. If the string - cannot be parsed, this function raises a :py:exc:`ValueError` exception. + Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if + ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is + not color or a palette image, converts the RGB value to a grayscale value. + If the string cannot be parsed, this function raises a :py:exc:`ValueError` + exception. .. versionadded:: 1.1.4 :param color: A color string - :return: ``(graylevel [, alpha]) or (red, green, blue[, alpha])`` + :param mode: Convert result to this mode + :return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])`` """ # same as getrgb, but converts the result to the given mode - color, alpha = getrgb(color), 255 - if len(color) == 4: - color, alpha = color[0:3], color[3] + rgb, alpha = getrgb(color), 255 + if len(rgb) == 4: + alpha = rgb[3] + rgb = rgb[:3] - if Image.getmodebase(mode) == "L": - r, g, b = color + if mode == "HSV": + from colorsys import rgb_to_hsv + + r, g, b = rgb + h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255) + return int(h * 255), int(s * 255), int(v * 255) + elif Image.getmodebase(mode) == "L": + r, g, b = rgb # ITU-R Recommendation 601-2 for nonlinear RGB # scaled to 24 bits to match the convert's implementation. - color = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 - if mode[-1] == "A": - return (color, alpha) - else: + graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 if mode[-1] == "A": - return color + (alpha,) - return color + return graylevel, alpha + return graylevel + elif mode[-1] == "A": + return rgb + (alpha,) + return rgb -colormap = { +colormap: dict[str, str | tuple[int, int, int]] = { # X11 colour table from https://drafts.csswg.org/css-color-4/, with # gray/grey spelling issues fixed. This is a superset of HTML 4.0 # colour names used in CSS 1. diff --git a/dependencies/WIN/PIL/ImageDraw.py b/dependencies/WIN/PIL/ImageDraw.py index aea0cc6..d8e4c0c 100644 --- a/dependencies/WIN/PIL/ImageDraw.py +++ b/dependencies/WIN/PIL/ImageDraw.py @@ -29,11 +29,29 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import math -import numbers +import struct +from collections.abc import Sequence +from types import ModuleType +from typing import TYPE_CHECKING, Any, AnyStr, Callable, Union, cast -from . import Image, ImageColor, ImageFont +from . import Image, ImageColor +from ._deprecate import deprecate +from ._typing import Coords + +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] | None +try: + Outline = Image.core.outline +except AttributeError: + Outline = None + +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + +_Ink = Union[float, tuple[int, ...], str] """ A simple 2D drawing interface for PIL images. @@ -44,7 +62,11 @@ class ImageDraw: - def __init__(self, im, mode=None): + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None + + def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ Create a drawing instance. @@ -65,7 +87,8 @@ def __init__(self, im, mode=None): if mode == "RGBA" and im.mode == "RGB": blend = 1 else: - raise ValueError("mode mismatch") + msg = "mode mismatch" + raise ValueError(msg) if mode == "P": self.palette = im.palette else: @@ -83,13 +106,27 @@ def __init__(self, im, mode=None): self.fontmode = "1" else: self.fontmode = "L" # aliasing is okay for other modes - self.fill = 0 - self.font = None + self.fill = False - def getfont(self): + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: """ Get the current default font. + To set the default font for this ImageDraw instance:: + + from PIL import ImageDraw, ImageFont + draw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + To set the default font for all future ImageDraw instances:: + + from PIL import ImageDraw, ImageFont + ImageDraw.ImageDraw.font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + If the current default font is ``None``, + it is initialized with ``ImageFont.load_default()``. + :returns: An image font.""" if not self.font: # FIXME: should add a font repository @@ -98,34 +135,57 @@ def getfont(self): self.font = ImageFont.load_default() return self.font - def _getink(self, ink, fill=None): + def _getfont( + self, font_size: float | None + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: + if font_size is not None: + from . import ImageFont + + return ImageFont.load_default(font_size) + else: + return self.getfont() + + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None if ink is None and fill is None: if self.fill: - fill = self.ink + result_fill = self.ink else: - ink = self.ink + result_ink = self.ink else: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) - if self.palette and not isinstance(ink, numbers.Number): + if self.palette and isinstance(ink, tuple): ink = self.palette.getcolor(ink, self._image) - ink = self.draw.draw_ink(ink) + result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) - if self.palette and not isinstance(fill, numbers.Number): + if self.palette and isinstance(fill, tuple): fill = self.palette.getcolor(fill, self._image) - fill = self.draw.draw_ink(fill) - return ink, fill + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill - def arc(self, xy, start, end, fill=None, width=1): + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy, bitmap, fill=None): + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -134,53 +194,94 @@ def bitmap(self, xy, bitmap, fill=None): if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy, start, end, fill=None, outline=None, width=1): + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a chord.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_chord(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy, fill=None, outline=None, width=1): + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an ellipse.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_ellipse(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy, fill=None, width=0, joint=None): + def circle( + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: + """Draw a circle given center coordinates and a radius.""" + ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) + self.ellipse(ellipse_xy, fill, outline, width) + + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - if not isinstance(xy[0], (list, tuple)): - xy = [tuple(xy[i : i + 2]) for i in range(0, len(xy), 2)] - for i in range(1, len(xy) - 1): - point = xy[i] + points: Sequence[Sequence[float]] + if isinstance(xy[0], (list, tuple)): + points = cast(Sequence[Sequence[float]], xy) + else: + points = [ + cast(Sequence[float], tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] + for i in range(1, len(points) - 1): + point = points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 - for start, end in ((xy[i - 1], point), (point, xy[i + 1])) + for start, end in ( + (points[i - 1], point), + (point, points[i + 1]), + ) ] if angles[0] == angles[1]: # This is a straight line, so no joint is required continue - def coord_at_angle(coord, angle): + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, ...]: x, y = coord angle -= 90 distance = width / 2 - 1 return tuple( - [ - p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d)) - for p, p_d in ( - (x, distance * math.cos(math.radians(angle))), - (y, distance * math.sin(math.radians(angle))), - ) - ] + p + (math.floor(p_d) if p_d > 0 else math.ceil(p_d)) + for p, p_d in ( + (x, distance * math.cos(math.radians(angle))), + (y, distance * math.sin(math.radians(angle))), + ) ) flipped = ( @@ -199,94 +300,173 @@ def coord_at_angle(coord, angle): if width > 8: # Cover potential gaps between the line and the joint if flipped: - gapCoords = [ + gap_coords = [ coord_at_angle(point, angles[0] + 90), point, coord_at_angle(point, angles[1] + 90), ] else: - gapCoords = [ + gap_coords = [ coord_at_angle(point, angles[0] - 90), point, coord_at_angle(point, angles[1] - 90), ] - self.line(gapCoords, fill, width=3) + self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None): + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: """(Experimental) Draw a shape.""" shape.close() - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_outline(shape, fill, 1) - if ink is not None and ink != fill: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: self.draw.draw_outline(shape, ink, 0) - def pieslice(self, xy, start, end, fill=None, outline=None, width=1): + def pieslice( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a pieslice.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_pieslice(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy, fill=None): + def point(self, xy: Coords, fill: _Ink | None = None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy, fill=None, outline=None): + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a polygon.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_polygon(xy, fill, 1) - if ink is not None and ink != fill: - self.draw.draw_polygon(xy, ink, 0) + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: + if width == 1: + self.draw.draw_polygon(xy, ink, 0, width) + elif self.im is not None: + # To avoid expanding the polygon outwards, + # use the fill as a mask + mask = Image.new("1", self.im.size) + mask_ink = self._getink(1)[0] + + fill_im = mask.copy() + draw = Draw(fill_im) + draw.draw.draw_polygon(xy, mask_ink, 1) + + ink_im = mask.copy() + draw = Draw(ink_im) + width = width * 2 - 1 + draw.draw.draw_polygon(xy, mask_ink, 0, width) + + mask.paste(ink_im, mask=fill_im) + + im = Image.new(self.mode, self.im.size) + draw = Draw(im) + draw.draw.draw_polygon(xy, ink, 0, width) + self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None - ): + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) - self.polygon(xy, fill, outline) + self.polygon(xy, fill, outline, width) - def rectangle(self, xy, fill=None, outline=None, width=1): + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a rectangle.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_rectangle(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) - def rounded_rectangle(self, xy, radius=0, fill=None, outline=None, width=1): + def rounded_rectangle( + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, + ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): - (x0, y0), (x1, y1) = xy + (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) else: - x0, y0, x1, y1 = xy + x0, y0, x1, y1 = cast(Sequence[float], xy) + if x1 < x0: + msg = "x1 must be greater than or equal to x0" + raise ValueError(msg) + if y1 < y0: + msg = "y1 must be greater than or equal to y0" + raise ValueError(msg) + if corners is None: + corners = (True, True, True, True) d = radius * 2 - full_x = d >= x1 - x0 - if full_x: - # The two left and two right corners are joined - d = x1 - x0 - full_y = d >= y1 - y0 - if full_y: - # The two top and two bottom corners are joined - d = y1 - y0 - if full_x and full_y: - # If all corners are joined, that is a circle - return self.ellipse(xy, fill, outline, width) - - if d == 0: - # If the corners have no curve, that is a rectangle + x0 = round(x0) + y0 = round(y0) + x1 = round(x1) + y1 = round(y1) + full_x, full_y = False, False + if all(corners): + full_x = d >= x1 - x0 - 1 + if full_x: + # The two left and two right corners are joined + d = x1 - x0 + full_y = d >= y1 - y0 - 1 + if full_y: + # The two top and two bottom corners are joined + d = y1 - y0 + if full_x and full_y: + # If all corners are joined, that is a circle + return self.ellipse(xy, fill, outline, width) + + if d == 0 or not any(corners): + # If the corners have no curve, + # or there are no corners, + # that is a rectangle return self.rectangle(xy, fill, outline, width) - r = d // 2 - ink, fill = self._getink(outline, fill) + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) - def draw_corners(pieslice): + def draw_corners(pieslice: bool) -> None: + parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves parts = ( @@ -301,75 +481,128 @@ def draw_corners(pieslice): ) else: # Draw four separate corners - parts = ( - ((x1 - d, y0, x1, y0 + d), 270, 360), - ((x1 - d, y1 - d, x1, y1), 0, 90), - ((x0, y1 - d, x0 + d, y1), 90, 180), - ((x0, y0, x0 + d, y0 + d), 180, 270), + parts = tuple( + part + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) + ) + if corners[i] ) for part in parts: if pieslice: - self.draw.draw_pieslice(*(part + (fill, 1))) + self.draw.draw_pieslice(*(part + (fill_ink, 1))) else: self.draw.draw_arc(*(part + (ink, width))) - if fill is not None: + if fill_ink is not None: draw_corners(True) if full_x: - self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill, 1) - else: - self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) + elif x1 - r - 1 > x0 + r + 1: + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: - self.draw.draw_rectangle((x0, y0 + r + 1, x0 + r, y1 - r - 1), fill, 1) - self.draw.draw_rectangle((x1 - r, y0 + r + 1, x1, y1 - r - 1), fill, 1) - if ink is not None and ink != fill and width != 0: + left = [x0, y0, x0 + r, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, fill_ink, 1) + + right = [x1 - r, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: draw_corners(False) if not full_x: - self.draw.draw_rectangle( - (x0 + r + 1, y0, x1 - r - 1, y0 + width - 1), ink, 1 - ) - self.draw.draw_rectangle( - (x0 + r + 1, y1 - width + 1, x1 - r - 1, y1), ink, 1 - ) + top = [x0, y0, x1, y0 + width - 1] + if corners[0]: + top[0] += r + 1 + if corners[1]: + top[2] -= r + 1 + self.draw.draw_rectangle(top, ink, 1) + + bottom = [x0, y1 - width + 1, x1, y1] + if corners[3]: + bottom[0] += r + 1 + if corners[2]: + bottom[2] -= r + 1 + self.draw.draw_rectangle(bottom, ink, 1) if not full_y: - self.draw.draw_rectangle( - (x0, y0 + r + 1, x0 + width - 1, y1 - r - 1), ink, 1 - ) - self.draw.draw_rectangle( - (x1 - width + 1, y0 + r + 1, x1, y1 - r - 1), ink, 1 - ) - - def _multiline_check(self, text): - """Draw text.""" + left = [x0, y0, x0 + width - 1, y1] + if corners[0]: + left[1] += r + 1 + if corners[3]: + left[3] -= r + 1 + self.draw.draw_rectangle(left, ink, 1) + + right = [x1 - width + 1, y0, x1, y1] + if corners[1]: + right[1] += r + 1 + if corners[2]: + right[3] -= r + 1 + self.draw.draw_rectangle(right, ink, 1) + + def _multiline_check(self, text: AnyStr) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" + def _multiline_split(self, text: AnyStr) -> list[AnyStr]: + return text.split("\n" if isinstance(text, str) else b"\n") - return text.split(split_character) + def _multiline_spacing( + self, + font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont, + spacing: float, + stroke_width: float, + ) -> float: + return ( + self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3] + + stroke_width + + spacing + ) def text( self, - xy, - text, - fill=None, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - stroke_fill=None, - embedded_color=False, - *args, - **kwargs, - ): + xy: tuple[float, float], + text: AnyStr, + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *args: Any, + **kwargs: Any, + ) -> None: + """Draw text.""" + if embedded_color and self.mode not in ("RGB", "RGBA"): + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(kwargs.get("font_size")) + if self._multiline_check(text): return self.multiline_text( xy, @@ -387,25 +620,23 @@ def text( embedded_color, ) - if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") - - if font is None: - font = self.getfont() - - def getink(fill): - ink, fill = self._getink(fill) + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) if ink is None: - return fill + assert fill_ink is not None + return fill_ink return ink - def draw_text(ink, stroke_width=0, stroke_offset=None): + def draw_text(ink: int, stroke_width: float = 0) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" - coord = xy + coord = [] + for i in range(2): + coord.append(int(xy[i])) + start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) try: - mask, offset = font.getmask2( + mask, offset = font.getmask2( # type: ignore[union-attr,misc] text, mode, direction=direction, @@ -414,13 +645,14 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): stroke_width=stroke_width, anchor=anchor, ink=ink, + start=start, *args, **kwargs, ) - coord = coord[0] + offset[0], coord[1] + offset[1] + coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: - mask = font.getmask( + mask = font.getmask( # type: ignore[misc] text, mode, direction, @@ -429,20 +661,23 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): stroke_width, anchor, ink, + start=start, *args, **kwargs, ) except TypeError: mask = font.getmask(text) - if stroke_offset: - coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1] if mode == "RGBA": # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A # extract mask and set text alpha color, mask = mask, mask.getband(3) - color.fillband(3, (ink >> 24) & 0xFF) - coord2 = coord[0] + mask.size[0], coord[1] + mask.size[1] - self.im.paste(color, coord + coord2, mask) + ink_alpha = struct.pack("i", ink)[3] + color.fillband(3, ink_alpha) + x, y = coord + if self.im is not None: + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) else: self.draw.draw_bitmap(coord, mask, ink) @@ -464,36 +699,47 @@ def draw_text(ink, stroke_width=0, stroke_offset=None): def multiline_text( self, - xy, - text, - fill=None, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - stroke_fill=None, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + fill: _Ink | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + stroke_fill: _Ink | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> None: if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) + line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: line_width = self.textlength( line, font, direction=direction, features=features, language=language @@ -525,7 +771,8 @@ def multiline_text( elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) self.text( (left, top), @@ -542,93 +789,63 @@ def multiline_text( ) top += line_spacing - def textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - """Get the size of a given string, in pixels.""" - if self._multiline_check(text): - return self.multiline_textsize( - text, font, spacing, direction, features, language, stroke_width - ) - - if font is None: - font = self.getfont() - return font.getsize(text, direction, features, language, stroke_width) - - def multiline_textsize( - self, - text, - font=None, - spacing=4, - direction=None, - features=None, - language=None, - stroke_width=0, - ): - max_width = 0 - lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) - for line in lines: - line_width, line_height = self.textsize( - line, font, spacing, direction, features, language, stroke_width - ) - max_width = max(max_width, line_width) - return max_width, len(lines) * line_spacing - spacing - def textlength( self, - text, - font=None, - direction=None, - features=None, - language=None, - embedded_color=False, - ): + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): - raise ValueError("can't measure length of multiline text") + msg = "can't measure length of multiline text" + raise ValueError(msg) if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) if font is None: - font = self.getfont() + font = self._getfont(font_size) mode = "RGBA" if embedded_color else self.fontmode - try: - return font.getlength(text, mode, direction, features, language) - except AttributeError: - size = self.textsize( - text, font, direction=direction, features=features, language=language - ) - if direction == "ttb": - return size[1] - return size[0] + return font.getlength(text, mode, direction, features, language) def textbbox( self, - xy, - text, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: """Get the bounding box of a given string, in pixels.""" if embedded_color and self.mode not in ("RGB", "RGBA"): - raise ValueError("Embedded color supported only in RGB and RGBA modes") + msg = "Embedded color supported only in RGB and RGBA modes" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) if self._multiline_check(text): return self.multiline_textbbox( @@ -645,10 +862,6 @@ def textbbox( embedded_color, ) - if font is None: - font = self.getfont() - if not isinstance(font, ImageFont.FreeTypeFont): - raise ValueError("Only supported for TrueType fonts") mode = "RGBA" if embedded_color else self.fontmode bbox = font.getbbox( text, mode, direction, features, language, stroke_width, anchor @@ -657,34 +870,45 @@ def textbbox( def multiline_textbbox( self, - xy, - text, - font=None, - anchor=None, - spacing=4, - align="left", - direction=None, - features=None, - language=None, - stroke_width=0, - embedded_color=False, - ): + xy: tuple[float, float], + text: AnyStr, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, + anchor: str | None = None, + spacing: float = 4, + align: str = "left", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + embedded_color: bool = False, + *, + font_size: float | None = None, + ) -> tuple[float, float, float, float]: if direction == "ttb": - raise ValueError("ttb direction is unsupported for multiline text") + msg = "ttb direction is unsupported for multiline text" + raise ValueError(msg) if anchor is None: anchor = "la" elif len(anchor) != 2: - raise ValueError("anchor must be a 2 character string") + msg = "anchor must be a 2 character string" + raise ValueError(msg) elif anchor[1] in "tb": - raise ValueError("anchor not supported for multiline text") + msg = "anchor not supported for multiline text" + raise ValueError(msg) + + if font is None: + font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) - line_spacing = ( - self.textsize("A", font=font, stroke_width=stroke_width)[1] + spacing - ) + line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: line_width = self.textlength( line, @@ -703,7 +927,7 @@ def multiline_textbbox( elif anchor[1] == "d": top -= (len(lines) - 1) * line_spacing - bbox = None + bbox: tuple[float, float, float, float] | None = None for idx, line in enumerate(lines): left = xy[0] @@ -723,7 +947,8 @@ def multiline_textbbox( elif align == "right": left += width_difference else: - raise ValueError('align must be "left", "center" or "right"') + msg = 'align must be "left", "center" or "right"' + raise ValueError(msg) bbox_line = self.textbbox( (left, top), @@ -753,7 +978,7 @@ def multiline_textbbox( return bbox -def Draw(im, mode=None): +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -765,45 +990,38 @@ def Draw(im, mode=None): defaults to the mode of the image. """ try: - return im.getdraw(mode) + return getattr(im, "getdraw")(mode) except AttributeError: return ImageDraw(im, mode) -# experimental access to the outline API -try: - Outline = Image.core.outline -except AttributeError: - Outline = None - - -def getdraw(im=None, hints=None): +def getdraw( + im: Image.Image | None = None, hints: list[str] | None = None +) -> tuple[ImageDraw2.Draw | None, ModuleType]: """ - (Experimental) A more advanced 2D drawing interface for PIL images, - based on the WCK interface. - :param im: The image to draw in. - :param hints: An optional list of hints. + :param hints: An optional list of hints. Deprecated. :returns: A (drawing context, drawing resource factory) tuple. """ - # FIXME: this needs more work! - # FIXME: come up with a better 'hints' scheme. - handler = None - if not hints or "nicest" in hints: - try: - from . import _imagingagg as handler - except ImportError: - pass - if handler is None: - from . import ImageDraw2 as handler - if im: - im = handler.Draw(im) - return im, handler - - -def floodfill(image, xy, value, border=None, thresh=0): + if hints is not None: + deprecate("'hints' parameter", 12) + from . import ImageDraw2 + + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 + + +def floodfill( + image: Image.Image, + xy: tuple[int, int], + value: float | tuple[int, ...], + border: float | tuple[int, ...] | None = None, + thresh: float = 0, +) -> None: """ - (experimental) Fills a bounded region with a given color. + .. warning:: This method is experimental. + + Fills a bounded region with a given color. :param image: Target image. :param xy: Seed position (a 2-item coordinate tuple). See @@ -821,6 +1039,7 @@ def floodfill(image, xy, value, border=None, thresh=0): # based on an implementation by Eric S. Raymond # amended by yo1995 @20180806 pixel = image.load() + assert pixel is not None x, y = xy try: background = pixel[x, y] @@ -835,8 +1054,8 @@ def floodfill(image, xy, value, border=None, thresh=0): full_edge = set() while edge: new_edge = set() - for (x, y) in edge: # 4 adjacent method - for (s, t) in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): + for x, y in edge: # 4 adjacent method + for s, t in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): # If already processed, or if a coordinate is negative, skip if (s, t) in full_edge or s < 0 or t < 0: continue @@ -849,7 +1068,7 @@ def floodfill(image, xy, value, border=None, thresh=0): if border is None: fill = _color_diff(p, background) <= thresh else: - fill = p != value and p != border + fill = p not in (value, border) if fill: pixel[s, t] = value new_edge.add((s, t)) @@ -857,11 +1076,13 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): +def _compute_regular_polygon_vertices( + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float +) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. - :param bounding_circle: The bounding circle is a tuple defined + :param bounding_circle: The bounding circle is a sequence defined by a point and radius. The polygon is inscribed in this circle. (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) :param n_sides: Number of sides @@ -899,41 +1120,54 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - raise TypeError("n_sides should be an int") + msg = "n_sides should be an int" # type: ignore[unreachable] + raise TypeError(msg) if n_sides < 3: - raise ValueError("n_sides should be an int > 2") + msg = "n_sides should be an int > 2" + raise ValueError(msg) # 1.2 Check `bounding_circle` has an appropriate value if not isinstance(bounding_circle, (list, tuple)): - raise TypeError("bounding_circle should be a tuple") + msg = "bounding_circle should be a sequence" + raise TypeError(msg) if len(bounding_circle) == 3: - *centroid, polygon_radius = bounding_circle - elif len(bounding_circle) == 2: - centroid, polygon_radius = bounding_circle + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(list[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(list[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) else: - raise ValueError( + msg = ( "bounding_circle should contain 2D coordinates " "and a radius (e.g. (x, y, r) or ((x, y), r) )" ) - - if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - raise ValueError("bounding_circle should only contain numeric data") - - if not len(centroid) == 2: - raise ValueError( - "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - ) + raise ValueError(msg) if polygon_radius <= 0: - raise ValueError("bounding_circle radius should be > 0") + msg = "bounding_circle radius should be > 0" + raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - raise ValueError("rotation should be an int or float") + msg = "rotation should be an int or float" # type: ignore[unreachable] + raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point, degrees, centroid): + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -949,11 +1183,11 @@ def _apply_rotation(point, degrees, centroid): ), ) - def _compute_polygon_vertex(centroid, polygon_radius, angle): + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: start_point = [polygon_radius, 0] - return _apply_rotation(start_point, angle, centroid) + return _apply_rotation(start_point, angle) - def _get_angles(n_sides, rotation): + def _get_angles(n_sides: int, rotation: float) -> list[float]: angles = [] degrees = 360 / n_sides # Start with the bottom left polygon vertex @@ -969,16 +1203,16 @@ def _get_angles(n_sides, rotation): angles = _get_angles(n_sides, rotation) # 4. Compute Vertices - return [ - _compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles - ] + return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2): +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: """ Uses 1-norm distance to calculate difference between two values. """ - if isinstance(color2, tuple): - return sum([abs(color1[i] - color2[i]) for i in range(0, len(color2))]) - else: - return abs(color1 - color2) + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(0, len(second))) diff --git a/dependencies/WIN/PIL/ImageDraw2.py b/dependencies/WIN/PIL/ImageDraw2.py index 1f63110..3d68658 100644 --- a/dependencies/WIN/PIL/ImageDraw2.py +++ b/dependencies/WIN/PIL/ImageDraw2.py @@ -22,15 +22,18 @@ .. seealso:: :py:mod:`PIL.ImageDraw` """ +from __future__ import annotations +from typing import Any, AnyStr, BinaryIO from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath +from ._typing import Coords, StrOrBytesPath class Pen: """Stores an outline color and width.""" - def __init__(self, color, width=1, opacity=255): + def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) self.width = width @@ -38,14 +41,16 @@ def __init__(self, color, width=1, opacity=255): class Brush: """Stores a fill color""" - def __init__(self, color, opacity=255): + def __init__(self, color: str, opacity: int = 255) -> None: self.color = ImageColor.getrgb(color) class Font: """Stores a TrueType font and color""" - def __init__(self, color, file, size=12): + def __init__( + self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12 + ) -> None: # FIXME: add support for bitmap fonts self.color = ImageColor.getrgb(color) self.font = ImageFont.truetype(file, size) @@ -56,17 +61,32 @@ class Draw: (Experimental) WCK-style drawing interface """ - def __init__(self, image, size=None, color=None): - if not hasattr(image, "im"): + def __init__( + self, + image: Image.Image | str, + size: tuple[int, int] | list[int] | None = None, + color: float | tuple[float, ...] | str | None = None, + ) -> None: + if isinstance(image, str): + if size is None: + msg = "If image argument is mode string, size must be a list or tuple" + raise ValueError(msg) image = Image.new(image, size, color) self.draw = ImageDraw.Draw(image) self.image = image - self.transform = None + self.transform: tuple[float, float, float, float, float, float] | None = None - def flush(self): + def flush(self) -> Image.Image: return self.image - def render(self, op, xy, pen, brush=None): + def render( + self, + op: str, + xy: Coords, + pen: Pen | Brush | None, + brush: Brush | Pen | None = None, + **kwargs: Any, + ) -> None: # handle color arguments outline = fill = None width = 1 @@ -82,63 +102,89 @@ def render(self, op, xy, pen, brush=None): fill = pen.color # handle transformation if self.transform: - xy = ImagePath.Path(xy) - xy.transform(self.transform) + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path # render the item - if op == "line": - self.draw.line(xy, fill=outline, width=width) + if op in ("arc", "line"): + kwargs.setdefault("fill", outline) else: - getattr(self.draw, op)(xy, fill=fill, outline=outline) + kwargs.setdefault("fill", fill) + kwargs.setdefault("outline", outline) + if op == "line": + kwargs.setdefault("width", width) + getattr(self.draw, op)(xy, **kwargs) - def settransform(self, offset): + def settransform(self, offset: tuple[float, float]) -> None: """Sets a transformation offset.""" (xoffset, yoffset) = offset self.transform = (1, 0, xoffset, 0, 1, yoffset) - def arc(self, xy, start, end, *options): + def arc( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Draws an arc (a portion of a circle outline) between the start and end angles, inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc` """ - self.render("arc", xy, start, end, *options) + self.render("arc", xy, pen, *options, start=start, end=end) - def chord(self, xy, start, end, *options): + def chord( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points with a straight line. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord` """ - self.render("chord", xy, start, end, *options) + self.render("chord", xy, pen, *options, start=start, end=end) - def ellipse(self, xy, *options): + def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws an ellipse inside the given bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse` """ - self.render("ellipse", xy, *options) + self.render("ellipse", xy, pen, *options) - def line(self, xy, *options): + def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a line between the coordinates in the ``xy`` list. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line` """ - self.render("line", xy, *options) + self.render("line", xy, pen, *options) - def pieslice(self, xy, start, end, *options): + def pieslice( + self, + xy: Coords, + pen: Pen | Brush | None, + start: float, + end: float, + *options: Any, + ) -> None: """ Same as arc, but also draws straight lines between the end points and the center of the bounding box. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice` """ - self.render("pieslice", xy, start, end, *options) + self.render("pieslice", xy, pen, *options, start=start, end=end) - def polygon(self, xy, *options): + def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a polygon. @@ -149,31 +195,49 @@ def polygon(self, xy, *options): .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon` """ - self.render("polygon", xy, *options) + self.render("polygon", xy, pen, *options) - def rectangle(self, xy, *options): + def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None: """ Draws a rectangle. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle` """ - self.render("rectangle", xy, *options) + self.render("rectangle", xy, pen, *options) - def text(self, xy, text, font): + def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None: """ Draws the string at the given position. .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text` """ if self.transform: - xy = ImagePath.Path(xy) - xy.transform(self.transform) + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path self.draw.text(xy, text, font=font.font, fill=font.color) - def textsize(self, text, font): + def textbbox( + self, xy: tuple[float, float], text: AnyStr, font: Font + ) -> tuple[float, float, float, float]: + """ + Returns bounding box (in pixels) of given text. + + :return: ``(left, top, right, bottom)`` bounding box + + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox` + """ + if self.transform: + path = ImagePath.Path(xy) + path.transform(self.transform) + xy = path + return self.draw.textbbox(xy, text, font=font.font) + + def textlength(self, text: AnyStr, font: Font) -> float: """ - Return the size of the given string, in pixels. + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. - .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textsize` + .. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength` """ - return self.draw.textsize(text, font=font.font) + return self.draw.textlength(text, font=font.font) diff --git a/dependencies/WIN/PIL/ImageEnhance.py b/dependencies/WIN/PIL/ImageEnhance.py index 3b79d5c..0e7e6dd 100644 --- a/dependencies/WIN/PIL/ImageEnhance.py +++ b/dependencies/WIN/PIL/ImageEnhance.py @@ -17,12 +17,16 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFilter, ImageStat class _Enhance: - def enhance(self, factor): + image: Image.Image + degenerate: Image.Image + + def enhance(self, factor: float) -> Image.Image: """ Returns an enhanced image. @@ -45,13 +49,15 @@ class Color(_Enhance): the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.intermediate_mode = "L" if "A" in image.getbands(): self.intermediate_mode = "LA" - self.degenerate = image.convert(self.intermediate_mode).convert(image.mode) + if self.intermediate_mode != image.mode: + image = image.convert(self.intermediate_mode).convert(image.mode) + self.degenerate = image class Contrast(_Enhance): @@ -59,16 +65,20 @@ class Contrast(_Enhance): This class can be used to control the contrast of an image, similar to the contrast control on a TV set. An enhancement factor of 0.0 - gives a solid grey image. A factor of 1.0 gives the original image. + gives a solid gray image. A factor of 1.0 gives the original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image - mean = int(ImageStat.Stat(image.convert("L")).mean[0] + 0.5) - self.degenerate = Image.new("L", image.size, mean).convert(image.mode) + if image.mode != "L": + image = image.convert("L") + mean = int(ImageStat.Stat(image).mean[0] + 0.5) + self.degenerate = Image.new("L", image.size, mean) + if self.degenerate.mode != self.image.mode: + self.degenerate = self.degenerate.convert(self.image.mode) - if "A" in image.getbands(): - self.degenerate.putalpha(image.getchannel("A")) + if "A" in self.image.getbands(): + self.degenerate.putalpha(self.image.getchannel("A")) class Brightness(_Enhance): @@ -79,7 +89,7 @@ class Brightness(_Enhance): original image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = Image.new(image.mode, image.size, 0) @@ -95,7 +105,7 @@ class Sharpness(_Enhance): original image, and a factor of 2.0 gives a sharpened image. """ - def __init__(self, image): + def __init__(self, image: Image.Image) -> None: self.image = image self.degenerate = image.filter(ImageFilter.SMOOTH) diff --git a/dependencies/WIN/PIL/ImageFile.py b/dependencies/WIN/PIL/ImageFile.py index 43d2bf0..5d0f87a 100644 --- a/dependencies/WIN/PIL/ImageFile.py +++ b/dependencies/WIN/PIL/ImageFile.py @@ -26,14 +26,22 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import abc import io +import itertools +import os import struct import sys -import warnings +from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast from . import Image -from ._util import isPath +from ._deprecate import deprecate +from ._util import is_path + +if TYPE_CHECKING: + from ._typing import StrOrBytesPath MAXBLOCK = 65536 @@ -49,7 +57,11 @@ -8: "bad configuration", -9: "out of memory error", } -"""Dict of known error codes returned from :meth:`.PyDecoder.decode`.""" +""" +Dict of known error codes returned from :meth:`.PyDecoder.decode`, +:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and +:meth:`.PyEncoder.encode_to_file`. +""" # @@ -57,30 +69,39 @@ # Helpers -def raise_oserror(error): +def _get_oserror(error: int, *, encoder: bool) -> OSError: try: - message = Image.core.getcodecstatus(error) + msg = Image.core.getcodecstatus(error) except AttributeError: - message = ERRORS.get(error) - if not message: - message = f"decoder error {error}" - raise OSError(message + " when reading image file") - - -def raise_ioerror(error): - warnings.warn( - "raise_ioerror is deprecated and will be removed in Pillow 9 (2022-01-02). " - "Use raise_oserror instead.", - DeprecationWarning, + msg = ERRORS.get(error) + if not msg: + msg = f"{'encoder' if encoder else 'decoder'} error {error}" + msg += f" when {'writing' if encoder else 'reading'} image file" + return OSError(msg) + + +def raise_oserror(error: int) -> OSError: + deprecate( + "raise_oserror", + 12, + action="It is only useful for translating error codes returned by a codec's " + "decode() method, which ImageFile already does automatically.", ) - return raise_oserror(error) + raise _get_oserror(error, encoder=False) -def _tilesort(t): +def _tilesort(t: _Tile) -> int: # sort on offset return t[2] +class _Tile(NamedTuple): + codec_name: str + extents: tuple[int, int, int, int] | None + offset: int = 0 + args: tuple[Any, ...] | str | None = None + + # # -------------------------------------------------------------------- # ImageFile base class @@ -89,32 +110,34 @@ def _tilesort(t): class ImageFile(Image.Image): """Base class for image file format handlers.""" - def __init__(self, fp=None, filename=None): + def __init__( + self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None + ) -> None: super().__init__() self._min_frame = 0 - self.custom_mimetype = None + self.custom_mimetype: str | None = None - self.tile = None - """ A list of tile descriptors, or ``None`` """ + self.tile: list[_Tile] = [] + """ A list of tile descriptors """ self.readonly = 1 # until we know better - self.decoderconfig = () + self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK - if isPath(fp): + if is_path(fp): # filename self.fp = open(fp, "rb") - self.filename = fp + self.filename = os.fspath(fp) self._exclusive_fp = True else: # stream - self.fp = fp - self.filename = filename + self.fp = cast(IO[bytes], fp) + self.filename = filename if filename is not None else "" # can be overridden - self._exclusive_fp = None + self._exclusive_fp = False try: try: @@ -128,21 +151,30 @@ def __init__(self, fp=None, filename=None): ) as v: raise SyntaxError(v) from v - if not self.mode or self.size[0] <= 0: - raise SyntaxError("not identified by this driver") + if not self.mode or self.size[0] <= 0 or self.size[1] <= 0: + msg = "not identified by this driver" + raise SyntaxError(msg) except BaseException: # close the file only if we have opened it this constructor if self._exclusive_fp: self.fp.close() raise - def get_format_mimetype(self): + def _open(self) -> None: + pass + + def get_format_mimetype(self) -> str | None: if self.custom_mimetype: return self.custom_mimetype if self.format is not None: return Image.MIME.get(self.format.upper()) + return None + + def __setstate__(self, state: list[Any]) -> None: + self.tile = [] + super().__setstate__(state) - def verify(self): + def verify(self) -> None: """Check file integrity""" # raise exception if something's wrong. must be called @@ -151,17 +183,18 @@ def verify(self): self.fp.close() self.fp = None - def load(self): + def load(self) -> Image.core.PixelAccess | None: """Load image data based on tile list""" - if self.tile is None: - raise OSError("cannot load this image") + if not self.tile and self._im is None: + msg = "cannot load this image" + raise OSError(msg) pixel = Image.Image.load(self) if not self.tile: return pixel - self.map = None + self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 # As of pypy 2.1.0, memory mapping was failing here. use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") @@ -169,24 +202,27 @@ def load(self): readonly = 0 # look for read/seek overrides - try: + if hasattr(self, "load_read"): read = self.load_read # don't use mmap if there are custom read/seek functions use_mmap = False - except AttributeError: + else: read = self.fp.read - try: + if hasattr(self, "load_seek"): seek = self.load_seek use_mmap = False - except AttributeError: + else: seek = self.fp.seek if use_mmap: # try memory mapping decoder_name, extents, offset, args = self.tile[0] + if isinstance(args, str): + args = (args, 0, 1) if ( decoder_name == "raw" + and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode and args[0] in Image._MAPMODES @@ -197,6 +233,9 @@ def load(self): with open(self.filename) as fp: self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) + if offset + self.size[1] * args[1] > self.map.size(): + msg = "buffer is not large enough" + raise OSError(msg) self.im = Image.core.map_buffer( self.map, self.size, decoder_name, offset, args ) @@ -214,22 +253,26 @@ def load(self): # sort tiles in file order self.tile.sort(key=_tilesort) - try: - # FIXME: This is a hack to handle TIFF's JpegTables tag. - prefix = self.tile_prefix - except AttributeError: - prefix = b"" + # FIXME: This is a hack to handle TIFF's JpegTables tag. + prefix = getattr(self, "tile_prefix", b"") + # Remove consecutive duplicates that only differ by their offset + self.tile = [ + list(tiles)[-1] + for _, tiles in itertools.groupby( + self.tile, lambda tile: (tile[0], tile[1], tile[3]) + ) + ] for decoder_name, extents, offset, args in self.tile: + seek(offset) decoder = Image._getdecoder( self.mode, decoder_name, args, self.decoderconfig ) try: - seek(offset) decoder.setimage(self.im, extents) if decoder.pulls_fd: decoder.setfd(self.fp) - status, err_code = decoder.decode(b"") + err_code = decoder.decode(b"")[1] else: b = prefix while True: @@ -240,16 +283,18 @@ def load(self): if LOAD_TRUNCATED_IMAGES: break else: - raise OSError("image file is truncated") from e + msg = "image file is truncated" + raise OSError(msg) from e if not s: # truncated jpeg if LOAD_TRUNCATED_IMAGES: break else: - raise OSError( + msg = ( "image file is truncated " f"({len(b)} bytes not processed)" ) + raise OSError(msg) b = b + s n, err_code = decoder.decode(b) @@ -271,45 +316,55 @@ def load(self): if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0: # still raised if decoder fails to return anything - raise_oserror(err_code) + raise _get_oserror(err_code, encoder=False) return Image.Image.load(self) - def load_prepare(self): + def load_prepare(self) -> None: # create image memory if necessary - if not self.im or self.im.mode != self.mode or self.im.size != self.size: + if self._im is None: self.im = Image.core.new(self.mode, self.size) # create palette (optional) if self.mode == "P": Image.Image.load(self) - def load_end(self): + def load_end(self) -> None: # may be overridden pass # may be defined for contained formats - # def load_seek(self, pos): + # def load_seek(self, pos: int) -> None: # pass # may be defined for blocked formats (e.g. PNG) - # def load_read(self, bytes): + # def load_read(self, read_bytes: int) -> bytes: # pass - def _seek_check(self, frame): + def _seek_check(self, frame: int) -> bool: if ( frame < self._min_frame # Only check upper limit on frames if additional seek operations # are not required to do so or ( not (hasattr(self, "_n_frames") and self._n_frames is None) - and frame >= self.n_frames + self._min_frame + and frame >= getattr(self, "n_frames") + self._min_frame ) ): - raise EOFError("attempt to seek outside sequence") + msg = "attempt to seek outside sequence" + raise EOFError(msg) return self.tell() != frame +class StubHandler: + def open(self, im: StubImageFile) -> None: + pass + + @abc.abstractmethod + def load(self, im: StubImageFile) -> Image.Image: + pass + + class StubImageFile(ImageFile): """ Base class for stub image loaders. @@ -318,22 +373,26 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ - def _open(self): - raise NotImplementedError("StubImageFile subclass must implement _open") + def _open(self) -> None: + msg = "StubImageFile subclass must implement _open" + raise NotImplementedError(msg) - def load(self): + def load(self) -> Image.core.PixelAccess | None: loader = self._load() if loader is None: - raise OSError(f"cannot find loader for this {self.format} file") + msg = f"cannot find loader for this {self.format} file" + raise OSError(msg) image = loader.load(self) assert image is not None # become the other object (!) - self.__class__ = image.__class__ + self.__class__ = image.__class__ # type: ignore[assignment] self.__dict__ = image.__dict__ + return image.load() - def _load(self): + def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" - raise NotImplementedError("StubImageFile subclass must implement _load") + msg = "StubImageFile subclass must implement _load" + raise NotImplementedError(msg) class Parser: @@ -343,13 +402,13 @@ class Parser: """ incremental = None - image = None - data = None - decoder = None + image: Image.Image | None = None + data: bytes | None = None + decoder: Image.core.ImagingDecoder | PyDecoder | None = None offset = 0 finished = 0 - def reset(self): + def reset(self) -> None: """ (Consumer) Reset the parser. Note that you can only call this method immediately after you've created a parser; parser @@ -357,7 +416,7 @@ def reset(self): """ assert self.data is None, "cannot reuse parsers" - def feed(self, data): + def feed(self, data: bytes) -> None: """ (Consumer) Feed data to the parser. @@ -376,7 +435,6 @@ def feed(self, data): # parse what we have if self.decoder: - if self.offset > 0: # skip header skip = min(len(self.data), self.offset) @@ -394,27 +452,24 @@ def feed(self, data): if e < 0: # decoding error self.image = None - raise_oserror(e) + raise _get_oserror(e, encoder=False) else: # end of image return self.data = self.data[n:] elif self.image: - # if we end up here with no decoder, this file cannot # be incrementally parsed. wait until we've gotten all # available data pass else: - # attempt to open this file try: with io.BytesIO(self.data) as fp: im = Image.open(fp) except OSError: - # traceback.print_exc() pass # not enough data else: flag = hasattr(im, "load_seek") or hasattr(im, "load_read") @@ -437,13 +492,13 @@ def feed(self, data): self.image = im - def __enter__(self): + def __enter__(self) -> Parser: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() - def close(self): + def close(self) -> Image.Image: """ (Consumer) Close the stream. @@ -458,9 +513,11 @@ def close(self): self.feed(b"") self.data = self.decoder = None if not self.finished: - raise OSError("image was incomplete") + msg = "image was incomplete" + raise OSError(msg) if not self.image: - raise OSError("cannot parse this image") + msg = "cannot parse this image" + raise OSError(msg) if self.data: # incremental parsing not possible; reopen the file # not that we have all data @@ -475,7 +532,7 @@ def close(self): # -------------------------------------------------------------------- -def _save(im, fp, tile, bufsize=0): +def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -493,55 +550,52 @@ def _save(im, fp, tile, bufsize=0): # But, it would need at least the image size in most cases. RawEncode is # a tricky case. bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c - try: - stdout = fp == sys.stdout or fp == sys.stdout.buffer - except (OSError, AttributeError): - stdout = False - if stdout: - fp.flush() - return try: fh = fp.fileno() fp.flush() + _encode_tile(im, fp, tile, bufsize, fh) except (AttributeError, io.UnsupportedOperation) as exc: - # compress to Python file-compatible object - for e, b, o, a in tile: - e = Image._getencoder(im.mode, e, a, im.encoderconfig) - if o > 0: - fp.seek(o) - e.setimage(im.im, b) - if e.pushes_fd: - e.setfd(fp) - l, s = e.encode_to_pyfd() - else: - while True: - l, s, d = e.encode(bufsize) - fp.write(d) - if s: - break - if s < 0: - raise OSError(f"encoder error {s} when writing image file") from exc - e.cleanup() - else: - # slight speedup: compress to real file object - for e, b, o, a in tile: - e = Image._getencoder(im.mode, e, a, im.encoderconfig) - if o > 0: - fp.seek(o) - e.setimage(im.im, b) - if e.pushes_fd: - e.setfd(fp) - l, s = e.encode_to_pyfd() - else: - s = e.encode_to_file(fh, bufsize) - if s < 0: - raise OSError(f"encoder error {s} when writing image file") - e.cleanup() + _encode_tile(im, fp, tile, bufsize, None, exc) if hasattr(fp, "flush"): fp.flush() -def _safe_read(fp, size): +def _encode_tile( + im: Image.Image, + fp: IO[bytes], + tile: list[_Tile], + bufsize: int, + fh: int | None, + exc: BaseException | None = None, +) -> None: + for encoder_name, extents, offset, args in tile: + if offset > 0: + fp.seek(offset) + encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig) + try: + encoder.setimage(im.im, extents) + if encoder.pushes_fd: + encoder.setfd(fp) + errcode = encoder.encode_to_pyfd()[1] + else: + if exc: + # compress to Python file-compatible object + while True: + errcode, data = encoder.encode(bufsize)[1:] + fp.write(data) + if errcode: + break + else: + # slight speedup: compress to real file object + assert fh is not None + errcode = encoder.encode_to_file(fh, bufsize) + if errcode < 0: + raise _get_oserror(errcode, encoder=True) from exc + finally: + encoder.cleanup() + + +def _safe_read(fp: IO[bytes], size: int) -> bytes: """ Reads large blocks in a safe way. Unlike fp.read(n), this function doesn't trust the user. If the requested size is larger than @@ -559,92 +613,77 @@ def _safe_read(fp, size): if size <= SAFEBLOCK: data = fp.read(size) if len(data) < size: - raise OSError("Truncated File Read") + msg = "Truncated File Read" + raise OSError(msg) return data - data = [] - while size > 0: - block = fp.read(min(size, SAFEBLOCK)) + blocks: list[bytes] = [] + remaining_size = size + while remaining_size > 0: + block = fp.read(min(remaining_size, SAFEBLOCK)) if not block: break - data.append(block) - size -= len(block) - if sum(len(d) for d in data) < size: - raise OSError("Truncated File Read") - return b"".join(data) + blocks.append(block) + remaining_size -= len(block) + if sum(len(block) for block in blocks) < size: + msg = "Truncated File Read" + raise OSError(msg) + return b"".join(blocks) class PyCodecState: - def __init__(self): + def __init__(self) -> None: self.xsize = 0 self.ysize = 0 self.xoff = 0 self.yoff = 0 - def extents(self): - return (self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize) + def extents(self) -> tuple[int, int, int, int]: + return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize -class PyDecoder: - """ - Python implementation of a format decoder. Override this class and - add the decoding logic in the :meth:`decode` method. +class PyCodec: + fd: IO[bytes] | None - See :ref:`Writing Your Own File Decoder in Python` - """ - - _pulls_fd = False - - def __init__(self, mode, *args): - self.im = None + def __init__(self, mode: str, *args: Any) -> None: + self.im: Image.core.ImagingCore | None = None self.state = PyCodecState() self.fd = None self.mode = mode self.init(args) - def init(self, args): + def init(self, args: tuple[Any, ...]) -> None: """ - Override to perform decoder specific initialization + Override to perform codec specific initialization - :param args: Array of args items from the tile entry + :param args: Tuple of arg items from the tile entry :returns: None """ self.args = args - @property - def pulls_fd(self): - return self._pulls_fd - - def decode(self, buffer): - """ - Override to perform the decoding process. - - :param buffer: A bytes object with the data to be decoded. - :returns: A tuple of ``(bytes consumed, errcode)``. - If finished with decoding return <0 for the bytes consumed. - Err codes are from :data:`.ImageFile.ERRORS`. - """ - raise NotImplementedError() - - def cleanup(self): + def cleanup(self) -> None: """ - Override to perform decoder specific cleanup + Override to perform codec specific cleanup :returns: None """ pass - def setfd(self, fd): + def setfd(self, fd: IO[bytes]) -> None: """ - Called from ImageFile to set the python file-like object + Called from ImageFile to set the Python file-like object - :param fd: A python file-like object + :param fd: A Python file-like object :returns: None """ self.fd = fd - def setimage(self, im, extents=None): + def setimage( + self, + im: Image.core.ImagingCore, + extents: tuple[int, int, int, int] | None = None, + ) -> None: """ - Called from ImageFile to set the core output image for the decoder + Called from ImageFile to set the core output image for the codec :param im: A core image object :param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle @@ -669,31 +708,125 @@ def setimage(self, im, extents=None): self.state.ysize = y1 - y0 if self.state.xsize <= 0 or self.state.ysize <= 0: - raise ValueError("Size cannot be negative") + msg = "Size cannot be negative" + raise ValueError(msg) if ( self.state.xsize + self.state.xoff > self.im.size[0] or self.state.ysize + self.state.yoff > self.im.size[1] ): - raise ValueError("Tile cannot extend outside image") + msg = "Tile cannot extend outside image" + raise ValueError(msg) + + +class PyDecoder(PyCodec): + """ + Python implementation of a format decoder. Override this class and + add the decoding logic in the :meth:`decode` method. + + See :ref:`Writing Your Own File Codec in Python` + """ + + _pulls_fd = False + + @property + def pulls_fd(self) -> bool: + return self._pulls_fd + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + """ + Override to perform the decoding process. + + :param buffer: A bytes object with the data to be decoded. + :returns: A tuple of ``(bytes consumed, errcode)``. + If finished with decoding return -1 for the bytes consumed. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base decoder" + raise NotImplementedError(msg) - def set_as_raw(self, data, rawmode=None): + def set_as_raw( + self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = () + ) -> None: """ Convenience method to set the internal image from a stream of raw data :param data: Bytes to be set :param rawmode: The rawmode to be used for the decoder. If not specified, it will default to the mode of the image + :param extra: Extra arguments for the decoder. :returns: None """ if not rawmode: rawmode = self.mode - d = Image._getdecoder(self.mode, "raw", (rawmode)) + d = Image._getdecoder(self.mode, "raw", rawmode, extra) + assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) if s[0] >= 0: - raise ValueError("not enough image data") + msg = "not enough image data" + raise ValueError(msg) if s[1] != 0: - raise ValueError("cannot decode image data") + msg = "cannot decode image data" + raise ValueError(msg) + + +class PyEncoder(PyCodec): + """ + Python implementation of a format encoder. Override this class and + add the decoding logic in the :meth:`encode` method. + + See :ref:`Writing Your Own File Codec in Python` + """ + + _pushes_fd = False + + @property + def pushes_fd(self) -> bool: + return self._pushes_fd + + def encode(self, bufsize: int) -> tuple[int, int, bytes]: + """ + Override to perform the encoding process. + + :param bufsize: Buffer size. + :returns: A tuple of ``(bytes encoded, errcode, bytes)``. + If finished with encoding return 1 for the error code. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + msg = "unavailable in base encoder" + raise NotImplementedError(msg) + + def encode_to_pyfd(self) -> tuple[int, int]: + """ + If ``pushes_fd`` is ``True``, then this method will be used, + and ``encode()`` will only be called once. + + :returns: A tuple of ``(bytes consumed, errcode)``. + Err codes are from :data:`.ImageFile.ERRORS`. + """ + if not self.pushes_fd: + return 0, -8 # bad configuration + bytes_consumed, errcode, data = self.encode(0) + if data: + assert self.fd is not None + self.fd.write(data) + return bytes_consumed, errcode + + def encode_to_file(self, fh: int, bufsize: int) -> int: + """ + :param fh: File handle. + :param bufsize: Buffer size. + + :returns: If finished successfully, return 0. + Otherwise, return an error code. Err codes are from + :data:`.ImageFile.ERRORS`. + """ + errcode = 0 + while errcode == 0: + status, errcode, buf = self.encode(bufsize) + if status > 0: + os.write(fh, buf[status:]) + return errcode diff --git a/dependencies/WIN/PIL/ImageFilter.py b/dependencies/WIN/PIL/ImageFilter.py index d2ece37..b350e56 100644 --- a/dependencies/WIN/PIL/ImageFilter.py +++ b/dependencies/WIN/PIL/ImageFilter.py @@ -14,11 +14,23 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import functools +from collections.abc import Sequence +from types import ModuleType +from typing import TYPE_CHECKING, Any, Callable, cast + +if TYPE_CHECKING: + from . import _imaging + from ._typing import NumpyArray class Filter: - pass + @abc.abstractmethod + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + pass class MultibandFilter(Filter): @@ -26,38 +38,46 @@ class MultibandFilter(Filter): class BuiltinFilter(MultibandFilter): - def filter(self, image): + filterargs: tuple[Any, ...] + + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) return image.filter(*self.filterargs) class Kernel(BuiltinFilter): """ - Create a convolution kernel. The current version only - supports 3x3 and 5x5 integer and floating point kernels. - - In the current version, kernels can only be applied to - "L" and "RGB" images. - - :param size: Kernel size, given as (width, height). In the current - version, this must be (3,3) or (5,5). - :param kernel: A sequence containing kernel weights. - :param scale: Scale factor. If given, the result for each pixel is - divided by this value. The default is the sum of the - kernel weights. - :param offset: Offset. If given, this value is added to the result, - after it has been divided by the scale factor. + Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating + point kernels. + + Kernels can only be applied to "L" and "RGB" images. + + :param size: Kernel size, given as (width, height). This must be (3,3) or (5,5). + :param kernel: A sequence containing kernel weights. The kernel will be flipped + vertically before being applied to the image. + :param scale: Scale factor. If given, the result for each pixel is divided by this + value. The default is the sum of the kernel weights. + :param offset: Offset. If given, this value is added to the result, after it has + been divided by the scale factor. """ name = "Kernel" - def __init__(self, size, kernel, scale=None, offset=0): + def __init__( + self, + size: tuple[int, int], + kernel: Sequence[float], + scale: float | None = None, + offset: float = 0, + ) -> None: if scale is None: # default scale is sum of kernel scale = functools.reduce(lambda a, b: a + b, kernel) if size[0] * size[1] != len(kernel): - raise ValueError("not enough coefficients in kernel") + msg = "not enough coefficients in kernel" + raise ValueError(msg) self.filterargs = size, scale, offset, kernel @@ -74,13 +94,14 @@ class RankFilter(Filter): name = "Rank" - def __init__(self, size, rank): + def __init__(self, size: int, rank: int) -> None: self.size = size self.rank = rank - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: if image.mode == "P": - raise ValueError("cannot filter palette images") + msg = "cannot filter palette images" + raise ValueError(msg) image = image.expand(self.size // 2, self.size // 2) return image.rankfilter(self.size, self.rank) @@ -95,7 +116,7 @@ class MedianFilter(RankFilter): name = "Median" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size // 2 @@ -110,7 +131,7 @@ class MinFilter(RankFilter): name = "Min" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = 0 @@ -125,7 +146,7 @@ class MaxFilter(RankFilter): name = "Max" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size self.rank = size * size - 1 @@ -141,10 +162,10 @@ class ModeFilter(Filter): name = "Mode" - def __init__(self, size=3): + def __init__(self, size: int = 3) -> None: self.size = size - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.modefilter(self.size) @@ -153,16 +174,22 @@ class GaussianBlur(MultibandFilter): approximates a Gaussian kernel. For details on accuracy see - :param radius: Standard deviation of the Gaussian kernel. + :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two + numbers for x and y, or a single number for both. """ name = "GaussianBlur" - def __init__(self, radius=2): + def __init__(self, radius: float | Sequence[float] = 2) -> None: self.radius = radius - def filter(self, image): - return image.gaussian_blur(self.radius) + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.gaussian_blur(xy) class BoxBlur(MultibandFilter): @@ -172,18 +199,29 @@ class BoxBlur(MultibandFilter): which runs in linear time relative to the size of the image for any radius value. - :param radius: Size of the box in one direction. Radius 0 does not blur, - returns an identical image. Radius 1 takes 1 pixel - in each direction, i.e. 9 pixels in total. + :param radius: Size of the box in a direction. Either a sequence of two numbers for + x and y, or a single number for both. + + Radius 0 does not blur, returns an identical image. + Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total. """ name = "BoxBlur" - def __init__(self, radius): + def __init__(self, radius: float | Sequence[float]) -> None: + xy = radius if isinstance(radius, (tuple, list)) else (radius, radius) + if xy[0] < 0 or xy[1] < 0: + msg = "radius must be >= 0" + raise ValueError(msg) self.radius = radius - def filter(self, image): - return image.box_blur(self.radius) + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: + xy = self.radius + if isinstance(xy, (int, float)): + xy = (xy, xy) + if xy == (0, 0): + return image.copy() + return image.box_blur(xy) class UnsharpMask(MultibandFilter): @@ -199,16 +237,18 @@ class UnsharpMask(MultibandFilter): .. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking - """ # noqa: E501 + """ name = "UnsharpMask" - def __init__(self, radius=2, percent=150, threshold=3): + def __init__( + self, radius: float = 2, percent: int = 150, threshold: int = 3 + ) -> None: self.radius = radius self.percent = percent self.threshold = threshold - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: return image.unsharp_mask(self.radius, self.percent, self.threshold) @@ -353,9 +393,17 @@ class Color3DLUT(MultibandFilter): name = "Color 3D LUT" - def __init__(self, size, table, channels=3, target_mode=None, **kwargs): + def __init__( + self, + size: int | tuple[int, int, int], + table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, + channels: int = 3, + target_mode: str | None = None, + **kwargs: bool, + ) -> None: if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) self.size = size = self._check_size(size) self.channels = channels self.mode = target_mode @@ -366,23 +414,24 @@ def __init__(self, size, table, channels=3, target_mode=None, **kwargs): items = size[0] * size[1] * size[2] wrong_size = False - numpy = None + numpy: ModuleType | None = None if hasattr(table, "shape"): try: import numpy - except ImportError: # pragma: no cover + except ImportError: pass if numpy and isinstance(table, numpy.ndarray): + numpy_table: NumpyArray = table if copy_table: - table = table.copy() + numpy_table = numpy_table.copy() - if table.shape in [ + if numpy_table.shape in [ (items * channels,), (items, channels), (size[2], size[1], size[0], channels), ]: - table = table.reshape(items * channels) + table = numpy_table.reshape(items * channels) else: wrong_size = True @@ -392,42 +441,52 @@ def __init__(self, size, table, channels=3, target_mode=None, **kwargs): # Convert to a flat list if table and isinstance(table[0], (list, tuple)): - table, raw_table = [], table + raw_table = cast(Sequence[Sequence[int]], table) + flat_table: list[int] = [] for pixel in raw_table: if len(pixel) != channels: - raise ValueError( + msg = ( "The elements of the table should " - "have a length of {}.".format(channels) + f"have a length of {channels}." ) - table.extend(pixel) + raise ValueError(msg) + flat_table.extend(pixel) + table = flat_table if wrong_size or len(table) != items * channels: - raise ValueError( + msg = ( "The table should have either channels * size**3 float items " "or size**3 items of channels-sized tuples with floats. " f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. " f"Actual length: {len(table)}" ) + raise ValueError(msg) self.table = table @staticmethod - def _check_size(size): + def _check_size(size: Any) -> tuple[int, int, int]: try: _, _, _ = size except ValueError as e: - raise ValueError( - "Size should be either an integer or a tuple of three integers." - ) from e + msg = "Size should be either an integer or a tuple of three integers." + raise ValueError(msg) from e except TypeError: size = (size, size, size) - size = [int(x) for x in size] - for size1D in size: - if not 2 <= size1D <= 65: - raise ValueError("Size should be in [2, 65] range.") + size = tuple(int(x) for x in size) + for size_1d in size: + if not 2 <= size_1d <= 65: + msg = "Size should be in [2, 65] range." + raise ValueError(msg) return size @classmethod - def generate(cls, size, callback, channels=3, target_mode=None): + def generate( + cls, + size: int | tuple[int, int, int], + callback: Callable[[float, float, float], tuple[float, ...]], + channels: int = 3, + target_mode: str | None = None, + ) -> Color3DLUT: """Generates new LUT using provided callback. :param size: Size of the table. Passed to the constructor. @@ -439,29 +498,36 @@ def generate(cls, size, callback, channels=3, target_mode=None): :param target_mode: Passed to the constructor of the resulting lookup table. """ - size1D, size2D, size3D = cls._check_size(size) + size_1d, size_2d, size_3d = cls._check_size(size) if channels not in (3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) - table = [0] * (size1D * size2D * size3D * channels) + table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) idx_out = 0 - for b in range(size3D): - for g in range(size2D): - for r in range(size1D): + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): table[idx_out : idx_out + channels] = callback( - r / (size1D - 1), g / (size2D - 1), b / (size3D - 1) + r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1) ) idx_out += channels return cls( - (size1D, size2D, size3D), + (size_1d, size_2d, size_3d), table, channels=channels, target_mode=target_mode, _copy_table=False, ) - def transform(self, callback, with_normals=False, channels=None, target_mode=None): + def transform( + self, + callback: Callable[..., tuple[float, ...]], + with_normals: bool = False, + channels: int | None = None, + target_mode: str | None = None, + ) -> Color3DLUT: """Transforms the table values using provided callback and returns a new LUT with altered values. @@ -481,23 +547,24 @@ def transform(self, callback, with_normals=False, channels=None, target_mode=Non lookup table. """ if channels not in (None, 3, 4): - raise ValueError("Only 3 or 4 output channels are supported") + msg = "Only 3 or 4 output channels are supported" + raise ValueError(msg) ch_in = self.channels ch_out = channels or ch_in - size1D, size2D, size3D = self.size + size_1d, size_2d, size_3d = self.size - table = [0] * (size1D * size2D * size3D * ch_out) + table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out) idx_in = 0 idx_out = 0 - for b in range(size3D): - for g in range(size2D): - for r in range(size1D): + for b in range(size_3d): + for g in range(size_2d): + for r in range(size_1d): values = self.table[idx_in : idx_in + ch_in] if with_normals: values = callback( - r / (size1D - 1), - g / (size2D - 1), - b / (size3D - 1), + r / (size_1d - 1), + g / (size_2d - 1), + b / (size_3d - 1), *values, ) else: @@ -514,7 +581,7 @@ def transform(self, callback, with_normals=False, channels=None, target_mode=Non _copy_table=False, ) - def __repr__(self): + def __repr__(self) -> str: r = [ f"{self.__class__.__name__} from {self.table.__class__.__name__}", "size={:d}x{:d}x{:d}".format(*self.size), @@ -524,12 +591,12 @@ def __repr__(self): r.append(f"target_mode={self.mode}") return "<{}>".format(" ".join(r)) - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: from . import Image return image.color_lut_3d( self.mode or image.mode, - Image.LINEAR, + Image.Resampling.BILINEAR, self.channels, self.size[0], self.size[1], diff --git a/dependencies/WIN/PIL/ImageFont.py b/dependencies/WIN/PIL/ImageFont.py index e99ca21..d8c2655 100644 --- a/dependencies/WIN/PIL/ImageFont.py +++ b/dependencies/WIN/PIL/ImageFont.py @@ -25,29 +25,53 @@ # See the README file for information on usage and redistribution. # +from __future__ import annotations + import base64 import os import sys import warnings +from enum import IntEnum from io import BytesIO +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast from . import Image, features -from ._util import isDirectory, isPath +from ._typing import StrOrBytesPath +from ._util import DeferredError, is_path + +if TYPE_CHECKING: + from . import ImageFile + from ._imaging import ImagingFont + from ._imagingft import Font + -LAYOUT_BASIC = 0 -LAYOUT_RAQM = 1 +class Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: bytes | None -class _imagingft_not_installed: - # module placeholder - def __getattr__(self, id): - raise ImportError("The _imagingft C module is not installed") +class Layout(IntEnum): + BASIC = 0 + RAQM = 1 +MAX_STRING_LENGTH = 1_000_000 + + +core: ModuleType | DeferredError try: from . import _imagingft as core -except ImportError: - core = _imagingft_not_installed() +except ImportError as ex: + core = DeferredError.new(ex) + + +def _string_length_check(text: str | bytes | bytearray) -> None: + if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: + msg = "too many characters in string" + raise ValueError(msg) # FIXME: add support for pilfont2 format (see FontFile.py) @@ -67,17 +91,20 @@ def __getattr__(self, id): class ImageFont: - "PIL font wrapper" + """PIL font wrapper""" - def _load_pilfont(self, filename): + font: ImagingFont + def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: - image = None + image: ImageFile.ImageFile | None = None + root = os.path.splitext(filename)[0] + for ext in (".png", ".gif", ".pbm"): if image: image.close() try: - fullname = os.path.splitext(filename)[0] + ext + fullname = root + ext image = Image.open(fullname) except Exception: pass @@ -87,18 +114,20 @@ def _load_pilfont(self, filename): else: if image: image.close() - raise OSError("cannot find glyph data file") + + msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}" + raise OSError(msg) self.file = fullname self._load_pilfont_data(fp, image) image.close() - def _load_pilfont_data(self, file, image): - + def _load_pilfont_data(self, file: IO[bytes], image: Image.Image) -> None: # read PILfont header if file.readline() != b"PILfont\n": - raise SyntaxError("Not a PILfont file") + msg = "Not a PILfont file" + raise SyntaxError(msg) file.readline().split(b";") self.info = [] # FIXME: should be a dictionary while True: @@ -112,23 +141,16 @@ def _load_pilfont_data(self, file, image): # check image if image.mode not in ("1", "L"): - raise TypeError("invalid font image mode") + msg = "invalid font image mode" + raise TypeError(msg) image.load() self.font = Image.core.font(image.im, data) - def getsize(self, text, *args, **kwargs): - """ - Returns width and height (in pixels) of given text. - - :param text: Text to measure. - - :return: (width, height) - """ - return self.font.getsize(text) - - def getmask(self, text, mode="", *args, **kwargs): + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: """ Create a bitmap for the text. @@ -146,8 +168,39 @@ def getmask(self, text, mode="", *args, **kwargs): :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ + _string_length_check(text) + Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) + def getbbox( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> tuple[int, int, int, int]: + """ + Returns bounding box (in pixels) of given text. + + .. versionadded:: 9.2.0 + + :param text: Text to render. + + :return: ``(left, top, right, bottom)`` bounding box + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return 0, 0, width, height + + def getlength( + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any + ) -> int: + """ + Returns length (in pixels) of given text. + This is the amount by which following text should be offset. + + .. versionadded:: 9.2.0 + """ + _string_length_check(text) + width, height = self.font.getsize(text) + return width + ## # Wrapper for FreeType fonts. Application code should use the @@ -155,11 +208,28 @@ def getmask(self, text, mode="", *args, **kwargs): class FreeTypeFont: - "FreeType font wrapper (requires _imagingft service)" + """FreeType font wrapper (requires _imagingft service)""" + + font: Font + font_bytes: bytes - def __init__(self, font=None, size=10, index=0, encoding="", layout_engine=None): + def __init__( + self, + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, + ) -> None: # FIXME: use service provider instead + if isinstance(core, DeferredError): + raise core.ex + + if size <= 0: + msg = f"font size must be greater than 0, not {size}" + raise ValueError(msg) + self.path = font self.size = size self.index = index @@ -170,34 +240,37 @@ def __init__(self, font=None, size=10, index=0, encoding="", layout_engine=None) except ImportError: pass else: - freetype_version = features.version_module("freetype2") - if freetype_version is not None and parse_version( - freetype_version - ) < parse_version("2.8"): - warnings.warn( - "Support for FreeType 2.7 is deprecated and will be removed" - " in Pillow 9 (2022-01-02). Please upgrade to FreeType 2.8 " - "or newer, preferably FreeType 2.10.4 which fixes " - "CVE-2020-15999.", - DeprecationWarning, - ) - - if layout_engine not in (LAYOUT_BASIC, LAYOUT_RAQM): - layout_engine = LAYOUT_BASIC + if freetype_version := features.version_module("freetype2"): + if parse_version(freetype_version) < parse_version("2.9.1"): + warnings.warn( + "Support for FreeType 2.9.0 is deprecated and will be removed " + "in Pillow 12 (2025-10-15). Please upgrade to FreeType 2.9.1 " + "or newer, preferably FreeType 2.10.4 which fixes " + "CVE-2020-15999.", + DeprecationWarning, + ) + + if layout_engine not in (Layout.BASIC, Layout.RAQM): + layout_engine = Layout.BASIC if core.HAVE_RAQM: - layout_engine = LAYOUT_RAQM - elif layout_engine == LAYOUT_RAQM and not core.HAVE_RAQM: - layout_engine = LAYOUT_BASIC + layout_engine = Layout.RAQM + elif layout_engine == Layout.RAQM and not core.HAVE_RAQM: + warnings.warn( + "Raqm layout was requested, but Raqm is not available. " + "Falling back to basic layout." + ) + layout_engine = Layout.BASIC self.layout_engine = layout_engine - def load_from_bytes(f): + def load_from_bytes(f: IO[bytes]) -> None: self.font_bytes = f.read() self.font = core.getfont( "", size, index, encoding, self.font_bytes, layout_engine ) - if isPath(font): + if is_path(font): + font = os.fspath(font) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -212,20 +285,23 @@ def load_from_bytes(f): font, size, index, encoding, layout_engine=layout_engine ) else: - load_from_bytes(font) + load_from_bytes(cast(IO[bytes], font)) + + def __getstate__(self) -> list[Any]: + return [self.path, self.size, self.index, self.encoding, self.layout_engine] - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" - return text.split(split_character) + def __setstate__(self, state: list[Any]) -> None: + path, size, index, encoding, layout_engine = state + FreeTypeFont.__init__(self, path, size, index, encoding, layout_engine) - def getname(self): + def getname(self) -> tuple[str | None, str | None]: """ :return: A tuple of the font family (e.g. Helvetica) and the font style (e.g. Bold) """ return self.font.family, self.font.style - def getmetrics(self): + def getmetrics(self) -> tuple[int, int]: """ :return: A tuple of the font ascent (the distance from the baseline to the highest outline point) and descent (the distance from the @@ -233,7 +309,14 @@ def getmetrics(self): """ return self.font.ascent, self.font.descent - def getlength(self, text, mode="", direction=None, features=None, language=None): + def getlength( + self, + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -248,27 +331,21 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) string due to kerning. If you need to adjust for kerning, include the following character and subtract its length. - For example, instead of - - .. code-block:: python + For example, instead of :: hello = font.getlength("Hello") world = font.getlength("World") hello_world = hello + world # not adjusted for kerning assert hello_world == font.getlength("HelloWorld") # may fail - use - - .. code-block:: python + use :: hello = font.getlength("HelloW") - font.getlength("W") # adjusted for kerning world = font.getlength("World") hello_world = hello + world # adjusted for kerning assert hello_world == font.getlength("HelloWorld") # True - or disable kerning with (requires libraqm) - - .. code-block:: python + or disable kerning with (requires libraqm) :: hello = draw.textlength("Hello", font, features=["-kern"]) world = draw.textlength("World", font, features=["-kern"]) @@ -295,7 +372,7 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -306,20 +383,21 @@ def getlength(self, text, mode="", direction=None, features=None, language=None) `_ Requires libraqm. - :return: Width for horizontal, height for vertical text. + :return: Either width for horizontal text, or height for vertical text. """ + _string_length_check(text) return self.font.getlength(text, mode, direction, features, language) / 64 def getbbox( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ) -> tuple[float, float, float, float]: """ Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -348,7 +426,7 @@ def getbbox( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. :param language: Language of the text. Different languages may use @@ -362,11 +440,13 @@ def getbbox( :param stroke_width: The width of the text stroke. :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. :return: ``(left, top, right, bottom)`` bounding box """ + _string_length_check(text) size, offset = self.font.getsize( text, mode, direction, features, language, anchor ) @@ -374,150 +454,18 @@ def getbbox( width, height = size[0] + 2 * stroke_width, size[1] + 2 * stroke_width return left, top, left + width, top + height - def getsize( - self, text, direction=None, features=None, language=None, stroke_width=0 - ): - """ - Returns width and height (in pixels) of given text if rendered in font with - provided direction, features, and language. - - Use :py:meth:`getlength()` to measure the offset of following text with - 1/64 pixel precision. - Use :py:meth:`getbbox()` to get the exact bounding box based on an anchor. - - .. note:: For historical reasons this function measures text height from - the ascender line instead of the top, see :ref:`text-anchors`. - If you wish to measure text height from the top, it is recommended - to use the bottom value of :meth:`getbbox` with ``anchor='lt'`` instead. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - .. versionadded:: 4.2.0 - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - `_ - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - # vertical offset is added for historical reasons - # see https://github.com/python-pillow/Pillow/pull/4910#discussion_r486682929 - size, offset = self.font.getsize(text, "L", direction, features, language) - return ( - size[0] + stroke_width * 2, - size[1] + stroke_width * 2 + offset[1], - ) - - def getsize_multiline( - self, - text, - direction=None, - spacing=4, - features=None, - language=None, - stroke_width=0, - ): - """ - Returns width and height (in pixels) of given text if rendered in font - with provided direction, features, and language, while respecting - newline characters. - - :param text: Text to measure. - - :param direction: Direction of the text. It can be 'rtl' (right to - left), 'ltr' (left to right) or 'ttb' (top to bottom). - Requires libraqm. - - :param spacing: The vertical gap between lines, defaulting to 4 pixels. - - :param features: A list of OpenType font features to be used during text - layout. This is usually used to turn on optional - font features that are not enabled by default, - for example 'dlig' or 'ss01', but can be also - used to turn off default font features for - example '-liga' to disable ligatures or '-kern' - to disable kerning. To get all supported - features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist - Requires libraqm. - - :param language: Language of the text. Different languages may use - different glyph shapes or ligatures. This parameter tells - the font which language the text is in, and to apply the - correct substitutions as appropriate, if available. - It should be a `BCP 47 language code - `_ - Requires libraqm. - - .. versionadded:: 6.0.0 - - :param stroke_width: The width of the text stroke. - - .. versionadded:: 6.2.0 - - :return: (width, height) - """ - max_width = 0 - lines = self._multiline_split(text) - line_spacing = self.getsize("A", stroke_width=stroke_width)[1] + spacing - for line in lines: - line_width, line_height = self.getsize( - line, direction, features, language, stroke_width - ) - max_width = max(max_width, line_width) - - return max_width, len(lines) * line_spacing - spacing - - def getoffset(self, text): - """ - Returns the offset of given text. This is the gap between the - starting coordinate and the first marking. Note that this gap is - included in the result of :py:func:`~PIL.ImageFont.FreeTypeFont.getsize`. - - :param text: Text to measure. - - :return: A tuple of the x and y offset - """ - return self.font.getsize(text)[1] - def getmask( self, - text, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ink=0, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + ) -> Image.core.ImagingCore: """ Create a bitmap for the text. @@ -547,7 +495,7 @@ def getmask( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -567,8 +515,9 @@ def getmask( .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -576,6 +525,11 @@ def getmask( .. versionadded:: 8.0.0 + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ @@ -588,22 +542,23 @@ def getmask( stroke_width=stroke_width, anchor=anchor, ink=ink, + start=start, )[0] def getmask2( self, - text, - mode="", - fill=Image.core.fill, - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ink=0, - *args, - **kwargs, - ): + text: str | bytes, + mode: str = "", + direction: str | None = None, + features: list[str] | None = None, + language: str | None = None, + stroke_width: float = 0, + anchor: str | None = None, + ink: int = 0, + start: tuple[float, float] | None = None, + *args: Any, + **kwargs: Any, + ) -> tuple[Image.core.ImagingCore, tuple[int, int]]: """ Create a bitmap for the text. @@ -633,7 +588,7 @@ def getmask2( example '-liga' to disable ligatures or '-kern' to disable kerning. To get all supported features, see - https://docs.microsoft.com/en-us/typography/opentype/spec/featurelist + https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist Requires libraqm. .. versionadded:: 4.2.0 @@ -653,8 +608,9 @@ def getmask2( .. versionadded:: 6.2.0 :param anchor: The text anchor alignment. Determines the relative location of - the anchor to the text. The default alignment is top left. - See :ref:`text-anchors` for valid values. + the anchor to the text. The default alignment is top left, + specifically ``la`` for horizontal text and ``lt`` for + vertical text. See :ref:`text-anchors` for details. .. versionadded:: 8.0.0 @@ -662,25 +618,46 @@ def getmask2( .. versionadded:: 8.0.0 + :param start: Tuple of horizontal and vertical offset, as text may render + differently when starting at fractional coordinates. + + .. versionadded:: 9.4.0 + :return: A tuple of an internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module, and the text offset, the gap between the starting coordinate and the first marking """ - size, offset = self.font.getsize( - text, mode, direction, features, language, anchor - ) - size = size[0] + stroke_width * 2, size[1] + stroke_width * 2 - offset = offset[0] - stroke_width, offset[1] - stroke_width - Image._decompression_bomb_check(size) - im = fill("RGBA" if mode == "RGBA" else "L", size, 0) - self.font.render( - text, im.id, mode, direction, features, language, stroke_width, ink + _string_length_check(text) + if start is None: + start = (0, 0) + + def fill(width: int, height: int) -> Image.core.ImagingCore: + size = (width, height) + Image._decompression_bomb_check(size) + return Image.core.fill("RGBA" if mode == "RGBA" else "L", size) + + return self.font.render( + text, + fill, + mode, + direction, + features, + language, + stroke_width, + anchor, + ink, + start[0], + start[1], ) - return im, offset def font_variant( - self, font=None, size=None, index=None, encoding=None, layout_engine=None - ): + self, + font: StrOrBytesPath | BinaryIO | None = None, + size: float | None = None, + index: int | None = None, + encoding: str | None = None, + layout_engine: Layout | None = None, + ) -> FreeTypeFont: """ Create a copy of this FreeTypeFont object, using any specified arguments to override the settings. @@ -690,15 +667,20 @@ def font_variant( :return: A FreeTypeFont object. """ + if font is None: + try: + font = BytesIO(self.font_bytes) + except AttributeError: + font = self.path return FreeTypeFont( - font=self.path if font is None else font, + font=font, size=self.size if size is None else size, index=self.index if index is None else index, encoding=self.encoding if encoding is None else encoding, layout_engine=layout_engine or self.layout_engine, ) - def get_variation_names(self): + def get_variation_names(self) -> list[bytes]: """ :returns: A list of the named styles in a variation font. :exception OSError: If the font is not a variation font. @@ -706,10 +688,11 @@ def get_variation_names(self): try: names = self.font.getvarnames() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e return [name.replace(b"\x00", b"") for name in names] - def set_variation_by_name(self, name): + def set_variation_by_name(self, name: str | bytes) -> None: """ :param name: The name of the style. :exception OSError: If the font is not a variation font. @@ -717,7 +700,7 @@ def set_variation_by_name(self, name): names = self.get_variation_names() if not isinstance(name, bytes): name = name.encode() - index = names.index(name) + index = names.index(name) + 1 if index == getattr(self, "_last_variation_index", None): # When the same name is set twice in a row, @@ -728,7 +711,7 @@ def set_variation_by_name(self, name): self.font.setvarname(index) - def get_variation_axes(self): + def get_variation_axes(self) -> list[Axis]: """ :returns: A list of the axes in a variation font. :exception OSError: If the font is not a variation font. @@ -736,12 +719,14 @@ def get_variation_axes(self): try: axes = self.font.getvaraxes() except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e for axis in axes: - axis["name"] = axis["name"].replace(b"\x00", b"") + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") return axes - def set_variation_by_axes(self, axes): + def set_variation_by_axes(self, axes: list[float]) -> None: """ :param axes: A list of values for each axis. :exception OSError: If the font is not a variation font. @@ -749,42 +734,61 @@ def set_variation_by_axes(self, axes): try: self.font.setvaraxes(axes) except AttributeError as e: - raise NotImplementedError("FreeType 2.9.1 or greater is required") from e + msg = "FreeType 2.9.1 or greater is required" + raise NotImplementedError(msg) from e class TransposedFont: - "Wrapper for writing rotated or mirrored text" + """Wrapper for writing rotated or mirrored text""" - def __init__(self, font, orientation=None): + def __init__( + self, font: ImageFont | FreeTypeFont, orientation: Image.Transpose | None = None + ): """ Wrapper that creates a transposed font from any existing font object. :param font: A font object. :param orientation: An optional orientation. If given, this should - be one of Image.FLIP_LEFT_RIGHT, Image.FLIP_TOP_BOTTOM, - Image.ROTATE_90, Image.ROTATE_180, or Image.ROTATE_270. + be one of Image.Transpose.FLIP_LEFT_RIGHT, Image.Transpose.FLIP_TOP_BOTTOM, + Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_180, or + Image.Transpose.ROTATE_270. """ self.font = font self.orientation = orientation # any 'transpose' argument, or None - def getsize(self, text, *args, **kwargs): - w, h = self.font.getsize(text) - if self.orientation in (Image.ROTATE_90, Image.ROTATE_270): - return h, w - return w, h - - def getmask(self, text, mode="", *args, **kwargs): + def getmask( + self, text: str | bytes, mode: str = "", *args: Any, **kwargs: Any + ) -> Image.core.ImagingCore: im = self.font.getmask(text, mode, *args, **kwargs) if self.orientation is not None: return im.transpose(self.orientation) return im - -def load(filename): + def getbbox( + self, text: str | bytes, *args: Any, **kwargs: Any + ) -> tuple[int, int, float, float]: + # TransposedFont doesn't support getmask2, move top-left point to (0, 0) + # this has no effect on ImageFont and simulates anchor="lt" for FreeTypeFont + left, top, right, bottom = self.font.getbbox(text, *args, **kwargs) + width = right - left + height = bottom - top + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + return 0, 0, height, width + return 0, 0, width, height + + def getlength(self, text: str | bytes, *args: Any, **kwargs: Any) -> float: + if self.orientation in (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270): + msg = "text length is undefined for text rotated by 90 or 270 degrees" + raise ValueError(msg) + return self.font.getlength(text, *args, **kwargs) + + +def load(filename: str) -> ImageFont: """ - Load a font file. This function loads a font object from the given - bitmap font file, and returns the corresponding font object. + Load a font file. This function loads a font object from the given + bitmap font file, and returns the corresponding font object. For loading TrueType + or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`. :param filename: Name of font file. :return: A font object. @@ -795,28 +799,42 @@ def load(filename): return f -def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): +def truetype( + font: StrOrBytesPath | BinaryIO, + size: float = 10, + index: int = 0, + encoding: str = "", + layout_engine: Layout | None = None, +) -> FreeTypeFont: """ Load a TrueType or OpenType font from a file or file-like object, - and create a font object. - This function loads a font object from the given file or file-like - object, and creates a font object for a font of the given size. - - Pillow uses FreeType to open font files. If you are opening many fonts - simultaneously on Windows, be aware that Windows limits the number of files - that can be open in C at once to 512. If you approach that limit, an + and create a font object. This function loads a font object from the given + file or file-like object, and creates a font object for a font of the given + size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load` + and :py:func:`~PIL.ImageFont.load_path`. + + Pillow uses FreeType to open font files. On Windows, be aware that FreeType + will keep the file open as long as the FreeTypeFont object exists. Windows + limits the number of files that can be open in C at once to 512, so if many + fonts are opened simultaneously and that limit is approached, an ``OSError`` may be thrown, reporting that FreeType "cannot open resource". + A workaround would be to copy the file(s) into memory, and open that instead. This function requires the _imagingft service. :param font: A filename or file-like object containing a TrueType font. If the file is not found in this filename, the loader may also - search in other directories, such as the :file:`fonts/` - directory on Windows or :file:`/Library/Fonts/`, - :file:`/System/Library/Fonts/` and :file:`~/Library/Fonts/` on - macOS. + search in other directories, such as: + + * The :file:`fonts/` directory on Windows, + * :file:`/Library/Fonts/`, :file:`/System/Library/Fonts/` + and :file:`~/Library/Fonts/` on macOS. + * :file:`~/.local/share/fonts`, :file:`/usr/local/share/fonts`, + and :file:`/usr/share/fonts` on Linux; or those specified by + the ``XDG_DATA_HOME`` and ``XDG_DATA_DIRS`` environment variables + for user-installed and system-wide fonts, respectively. - :param size: The requested size, in points. + :param size: The requested size, in pixels. :param index: Which font face to load (default is first available face). :param encoding: Which font encoding to use (default is Unicode). Possible encodings include (see the FreeType documentation for more @@ -838,7 +856,12 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): This specifies the character set to use. It does not alter the encoding of any text provided in subsequent operations. :param layout_engine: Which layout engine to use, if available: - :data:`.ImageFont.LAYOUT_BASIC` or :data:`.ImageFont.LAYOUT_RAQM`. + :attr:`.ImageFont.Layout.BASIC` or :attr:`.ImageFont.Layout.RAQM`. + If it is available, Raqm layout will be used by default. + Otherwise, basic layout will be used. + + Raqm layout is recommended for all non-English text. If Raqm layout + is not required, basic layout will have better performance. You can check support for Raqm layout using :py:func:`PIL.features.check_feature` with ``feature="raqm"``. @@ -846,15 +869,16 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): .. versionadded:: 4.2.0 :return: A font object. :exception OSError: If the file could not be read. + :exception ValueError: If the font size is not greater than zero. """ - def freetype(font): + def freetype(font: StrOrBytesPath | BinaryIO) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: return freetype(font) except OSError: - if not isPath(font): + if not is_path(font): raise ttf_filename = os.path.basename(font) @@ -867,12 +891,21 @@ def freetype(font): if windir: dirs.append(os.path.join(windir, "fonts")) elif sys.platform in ("linux", "linux2"): - lindirs = os.environ.get("XDG_DATA_DIRS", "") - if not lindirs: - # According to the freedesktop spec, XDG_DATA_DIRS should - # default to /usr/share - lindirs = "/usr/share" - dirs += [os.path.join(lindir, "fonts") for lindir in lindirs.split(":")] + data_home = os.environ.get("XDG_DATA_HOME") + if not data_home: + # The freedesktop spec defines the following default directory for + # when XDG_DATA_HOME is unset or empty. This user-level directory + # takes precedence over system-level directories. + data_home = os.path.expanduser("~/.local/share") + xdg_dirs = [data_home] + + data_dirs = os.environ.get("XDG_DATA_DIRS") + if not data_dirs: + # Similarly, defaults are defined for the system-level directories + data_dirs = "/usr/local/share:/usr/share" + xdg_dirs += data_dirs.split(":") + + dirs += [os.path.join(xdg_dir, "fonts") for xdg_dir in xdg_dirs] elif sys.platform == "darwin": dirs += [ "/Library/Fonts", @@ -898,7 +931,7 @@ def freetype(font): raise -def load_path(filename): +def load_path(filename: str | bytes) -> ImageFont: """ Load font file. Same as :py:func:`~PIL.ImageFont.load`, but searches for a bitmap font along the Python path. @@ -907,24 +940,21 @@ def load_path(filename): :return: A font object. :exception OSError: If the file could not be read. """ + if not isinstance(filename, str): + filename = filename.decode("utf-8") for directory in sys.path: - if isDirectory(directory): - if not isinstance(filename, str): - filename = filename.decode("utf-8") - try: - return load(os.path.join(directory, filename)) - except OSError: - pass - raise OSError("cannot find font file") - + try: + return load(os.path.join(directory, filename)) + except OSError: + pass + msg = f'cannot find font file "{filename}" in sys.path' + if os.path.exists(filename): + msg += f', did you mean ImageFont.load("{filename}") instead?' -def load_default(): - """Load a "better than nothing" default font. + raise OSError(msg) - .. versionadded:: 1.1.4 - :return: A font object. - """ +def load_default_imagefont() -> ImageFont: f = ImageFont() f._load_pilfont_data( # courB08 @@ -1058,3 +1088,251 @@ def load_default(): ), ) return f + + +def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: + """If FreeType support is available, load a version of Aileron Regular, + https://dotcolon.net/font/aileron, with a more limited character set. + + Otherwise, load a "better than nothing" font. + + .. versionadded:: 1.1.4 + + :param size: The font size of Aileron Regular. + + .. versionadded:: 10.1.0 + + :return: A font object. + """ + if isinstance(core, ModuleType) or size is not None: + return truetype( + BytesIO( + base64.b64decode( + b""" +AAEAAAAPAIAAAwBwRkZUTYwDlUAAADFoAAAAHEdERUYAqADnAAAo8AAAACRHUE9ThhmITwAAKfgAA +AduR1NVQnHxefoAACkUAAAA4k9TLzJovoHLAAABeAAAAGBjbWFw5lFQMQAAA6gAAAGqZ2FzcP//AA +MAACjoAAAACGdseWYmRXoPAAAGQAAAHfhoZWFkE18ayQAAAPwAAAA2aGhlYQboArEAAAE0AAAAJGh +tdHjjERZ8AAAB2AAAAdBsb2NhuOexrgAABVQAAADqbWF4cAC7AEYAAAFYAAAAIG5hbWUr+h5lAAAk +OAAAA6Jwb3N0D3oPTQAAJ9wAAAEKAAEAAAABGhxJDqIhXw889QALA+gAAAAA0Bqf2QAAAADhCh2h/ +2r/LgOxAyAAAAAIAAIAAAAAAAAAAQAAA8r/GgAAA7j/av9qA7EAAQAAAAAAAAAAAAAAAAAAAHQAAQ +AAAHQAQwAFAAAAAAACAAAAAQABAAAAQAAAAAAAAAADAfoBkAAFAAgCigJYAAAASwKKAlgAAAFeADI +BPgAAAAAFAAAAAAAAAAAAAAcAAAAAAAAAAAAAAABVS1dOAEAAIPsCAwL/GgDIA8oA5iAAAJMAAAAA +AhICsgAAACAAAwH0AAAAAAAAAU0AAADYAAAA8gA5AVMAVgJEAEYCRAA1AuQAKQKOAEAAsAArATsAZ +AE7AB4CMABVAkQAUADc/+EBEgAgANwAJQEv//sCRAApAkQAggJEADwCRAAtAkQAIQJEADkCRAArAk +QAMgJEACwCRAAxANwAJQDc/+ECRABnAkQAUAJEAEQB8wAjA1QANgJ/AB0CcwBkArsALwLFAGQCSwB +kAjcAZALGAC8C2gBkAQgAZAIgADcCYQBkAj8AZANiAGQCzgBkAuEALwJWAGQC3QAvAmsAZAJJADQC +ZAAiAqoAXgJuACADuAAaAnEAGQJFABMCTwAuATMAYgEv//sBJwAiAkQAUAH0ADIBLAApAhMAJAJjA +EoCEQAeAmcAHgIlAB4BIgAVAmcAHgJRAEoA7gA+AOn/8wIKAEoA9wBGA1cASgJRAEoCSgAeAmMASg +JnAB4BSgBKAcsAGAE5ABQCUABCAgIAAQMRAAEB4v/6AgEAAQHOABQBLwBAAPoAYAEvACECRABNA0Y +AJAItAHgBKgAcAkQAUAEsAHQAygAgAi0AOQD3ADYA9wAWAaEANgGhABYCbAAlAYMAeAGDADkA6/9q +AhsAFAIKABUB/QAVAAAAAwAAAAMAAAAcAAEAAAAAAKQAAwABAAAAHAAEAIgAAAAeABAAAwAOAH4Aq +QCrALEAtAC3ALsgGSAdICYgOiBEISL7Av//AAAAIACpAKsAsAC0ALcAuyAYIBwgJiA5IEQhIvsB// +//4/+5/7j/tP+y/7D/reBR4E/gR+A14CzfTwVxAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAEGAAABAAAAAAAAAAECAAAAAgAAAAAAAAAAAAAAAAAAAAEAAAMEBQYHCAkKCwwNDg8QERIT +FBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj9AQUJDREVGR0hJSktMT +U5PUFFSU1RVVldYWVpbXF1eX2BhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAA +AAAAAAYnFmAAAAAABlAAAAAAAAAAAAAAAAAAAAAAAAAAAAY2htAAAAAAAAAABrbGlqAAAAAHAAbm9 +ycwBnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmACYAJgAmAD4AUgCCAMoBCgFO +AVwBcgGIAaYBvAHKAdYB6AH2AgwCIAJKAogCpgLWAw4DIgNkA5wDugPUA+gD/AQQBEYEogS8BPoFJ +gVSBWoFgAWwBcoF1gX6BhQGJAZMBmgGiga0BuIHGgdUB2YHkAeiB8AH3AfyCAoIHAgqCDoITghcCG +oIogjSCPoJKglYCXwJwgnqCgIKKApACl4Klgq8CtwLDAs8C1YLjAuyC9oL7gwMDCYMSAxgDKAMrAz +qDQoNTA1mDYQNoA2uDcAN2g3oDfYODA4iDkoOXA5sDnoOnA7EDvwAAAAFAAAAAAH0ArwAAwAGAAkA +DAAPAAAxESERAxMhExcRASELARETAfT6qv6syKr+jgFUqsiqArz9RAGLAP/+1P8B/v3VAP8BLP4CA +P8AAgA5//IAuQKyAAMACwAANyMDMwIyFhQGIiY0oE4MZk84JCQ4JLQB/v3AJDgkJDgAAgBWAeUBPA +LfAAMABwAAEyMnMxcjJzOmRgpagkYKWgHl+vr6AAAAAAIARgAAAf4CsgAbAB8AAAEHMxUjByM3Iwc +jNyM1MzcjNTM3MwczNzMHMxUrAQczAZgdZXEvOi9bLzovWmYdZXEvOi9bLzovWp9bHlsBn4w429vb +2ziMONvb29s4jAAAAAMANf+mAg4DDAAfACYALAAAJRQGBxUjNS4BJzMeARcRLgE0Njc1MxUeARcjJ +icVHgEBFBYXNQ4BExU+ATU0Ag5xWDpgcgRcBz41Xl9oVTpVYwpcC1ttXP6cLTQuM5szOrVRZwlOTQ +ZqVzZECAEAGlukZAlOTQdrUG8O7iNlAQgxNhDlCDj+8/YGOjReAAAAAAUAKf/yArsCvAAHAAsAFQA +dACcAABIyFhQGIiY0EyMBMwQiBhUUFjI2NTQSMhYUBiImNDYiBhUUFjI2NTR5iFBQiFCVVwHAV/5c +OiMjOiPmiFBQiFCxOiMjOiMCvFaSVlaS/ZoCsjIzMC80NC8w/uNWklZWkhozMC80NC8wAAAAAgBA/ +/ICbgLAACIALgAAARUjEQYjIiY1NDY3LgE1NDYzMhcVJiMiBhUUFhcWOwE1MxUFFBYzMjc1IyIHDg +ECbmBcYYOOVkg7R4hsQjY4Q0RNRD4SLDxW/pJUXzksPCkUUk0BgUb+zBVUZ0BkDw5RO1huCkULQzp +COAMBcHDHRz0J/AIHRQAAAAEAKwHlAIUC3wADAAATIycze0YKWgHl+gAAAAABAGT/sAEXAwwACQAA +EzMGEBcjLgE0Nt06dXU6OUBAAwzG/jDGVePs4wAAAAEAHv+wANEDDAAJAAATMx4BFAYHIzYQHjo5Q +EA5OnUDDFXj7ONVxgHQAAAAAQBVAFIB2wHbAA4AAAE3FwcXBycHJzcnNxcnMwEtmxOfcTJjYzJxnx +ObCj4BKD07KYolmZkliik7PbMAAQBQAFUB9AIlAAsAAAEjFSM1IzUzNTMVMwH0tTq1tTq1AR/Kyjj +OzgAAAAAB/+H/iACMAGQABAAANwcjNzOMWlFOXVrS3AAAAQAgAP8A8gE3AAMAABMjNTPy0tIA/zgA +AQAl//IApQByAAcAADYyFhQGIiY0STgkJDgkciQ4JCQ4AAAAAf/7/+IBNALQAAMAABcjEzM5Pvs+H +gLuAAAAAAIAKf/yAhsCwAADAAcAABIgECA2IBAgKQHy/g5gATL+zgLA/TJEAkYAAAAAAQCCAAABlg +KyAAgAAAERIxEHNTc2MwGWVr6SIygCsv1OAldxW1sWAAEAPAAAAg4CwAAZAAA3IRUhNRM+ATU0JiM +iDwEjNz4BMzIWFRQGB7kBUv4x+kI2QTt+EAFWAQp8aGVtSl5GRjEA/0RVLzlLmAoKa3FsUkNxXQAA +AAEALf/yAhYCwAAqAAABHgEVFAYjIi8BMxceATMyNjU0KwE1MzI2NTQmIyIGDwEjNz4BMzIWFRQGA +YxBSZJo2RUBVgEHV0JBUaQREUBUQzc5TQcBVgEKfGhfcEMBbxJbQl1x0AoKRkZHPn9GSD80QUVCCg +pfbGBPOlgAAAACACEAAAIkArIACgAPAAAlIxUjNSE1ATMRMyMRBg8BAiRXVv6qAVZWV60dHLCurq4 +rAdn+QgFLMibzAAABADn/8gIZArIAHQAAATIWFRQGIyIvATMXFjMyNjU0JiMiByMTIRUhBzc2ATNv +d5Fl1RQBVgIad0VSTkVhL1IwAYj+vh8rMAHHgGdtgcUKCoFXTU5bYgGRRvAuHQAAAAACACv/8gITA +sAAFwAjAAABMhYVFAYjIhE0NjMyFh8BIycmIyIDNzYTMjY1NCYjIgYVFBYBLmp7imr0l3RZdAgBXA +IYZ5wKJzU6QVNJSz5SUAHSgWltiQFGxcNlVQoKdv7sPiz+ZF1LTmJbU0lhAAAAAQAyAAACGgKyAAY +AAAEVASMBITUCGv6oXAFL/oECsij9dgJsRgAAAAMALP/xAhgCwAAWACAALAAAAR4BFRQGIyImNTQ2 +Ny4BNTQ2MhYVFAYmIgYVFBYyNjU0AzI2NTQmIyIGFRQWAZQ5S5BmbIpPOjA7ecp5P2F8Q0J8RIVJS +0pLTEtOAW0TXTxpZ2ZqPF0SE1A3VWVlVTdQ/UU0N0RENzT9/ko+Ok1NOj1LAAIAMf/yAhkCwAAXAC +MAAAEyERQGIyImLwEzFxYzMhMHBiMiJjU0NhMyNjU0JiMiBhUUFgEl9Jd0WXQIAVwCGGecCic1SWp +7imo+UlBAQVNJAsD+usXDZVUKCnYBFD4sgWltif5kW1NJYV1LTmIAAAACACX/8gClAiAABwAPAAAS +MhYUBiImNBIyFhQGIiY0STgkJDgkJDgkJDgkAiAkOCQkOP52JDgkJDgAAAAC/+H/iAClAiAABwAMA +AASMhYUBiImNBMHIzczSTgkJDgkaFpSTl4CICQ4JCQ4/mba5gAAAQBnAB4B+AH0AAYAAAENARUlNS +UB+P6qAVb+bwGRAbCmpkbJRMkAAAIAUAC7AfQBuwADAAcAAAEhNSERITUhAfT+XAGk/lwBpAGDOP8 +AOAABAEQAHgHVAfQABgAAARUFNS0BNQHV/m8BVv6qAStEyUSmpkYAAAAAAgAj//IB1ALAABgAIAAA +ATIWFRQHDgEHIz4BNz4BNTQmIyIGByM+ARIyFhQGIiY0AQRibmktIAJWBSEqNig+NTlHBFoDezQ4J +CQ4JALAZ1BjaS03JS1DMD5LLDQ/SUVgcv2yJDgkJDgAAAAAAgA2/5gDFgKYADYAQgAAAQMGFRQzMj +Y1NCYjIg4CFRQWMzI2NxcGIyImNTQ+AjMyFhUUBiMiJwcGIyImNTQ2MzIfATcHNzYmIyIGFRQzMjY +Cej8EJjJJlnBAfGQ+oHtAhjUYg5OPx0h2k06Os3xRWQsVLjY5VHtdPBwJETcJDyUoOkZEJz8B0f74 +EQ8kZl6EkTFZjVOLlyknMVm1pmCiaTq4lX6CSCknTVRmmR8wPdYnQzxuSWVGAAIAHQAAAncCsgAHA +AoAACUjByMTMxMjATMDAcj+UVz4dO5d/sjPZPT0ArL9TgE6ATQAAAADAGQAAAJMArIAEAAbACcAAA +EeARUUBgcGKwERMzIXFhUUJRUzMjc2NTQnJiMTPgE1NCcmKwEVMzIBvkdHZkwiNt7LOSGq/oeFHBt +hahIlSTM+cB8Yj5UWAW8QT0VYYgwFArIEF5Fv1eMED2NfDAL93AU+N24PBP0AAAAAAQAv//ICjwLA +ABsAAAEyFh8BIycmIyIGFRQWMzI/ATMHDgEjIiY1NDYBdX+PCwFWAiKiaHx5ZaIiAlYBCpWBk6a0A +sCAagoKpqN/gaOmCgplhcicn8sAAAIAZAAAAp8CsgAMABkAAAEeARUUBgcGKwERMzITPgE1NCYnJi +sBETMyAY59lJp8IzXN0jUVWmdjWRs5d3I4Aq4QqJWUug8EArL9mQ+PeHGHDgX92gAAAAABAGQAAAI +vArIACwAAJRUhESEVIRUhFSEVAi/+NQHB/pUBTf6zRkYCskbwRvAAAAABAGQAAAIlArIACQAAExUh +FSERIxEhFboBQ/69VgHBAmzwRv7KArJGAAAAAAEAL//yAo8CwAAfAAABMxEjNQcGIyImNTQ2MzIWH +wEjJyYjIgYVFBYzMjY1IwGP90wfPnWTprSSf48LAVYCIqJofHllVG+hAU3+s3hARsicn8uAagoKpq +N/gaN1XAAAAAEAZAAAAowCsgALAAABESMRIREjETMRIRECjFb+hFZWAXwCsv1OAS7+0gKy/sQBPAA +AAAABAGQAAAC6ArIAAwAAMyMRM7pWVgKyAAABADf/8gHoArIAEwAAAREUBw4BIyImLwEzFxYzMjc2 +NREB6AIFcGpgbQIBVgIHfXQKAQKy/lYxIltob2EpKYyEFD0BpwAAAAABAGQAAAJ0ArIACwAACQEjA +wcVIxEzEQEzATsBJ3ntQlZWAVVlAWH+nwEnR+ACsv6RAW8AAQBkAAACLwKyAAUAACUVIREzEQIv/j +VWRkYCsv2UAAABAGQAAAMUArIAFAAAAREjETQ3BgcDIwMmJxYVESMRMxsBAxRWAiMxemx8NxsCVo7 +MywKy/U4BY7ZLco7+nAFmoFxLtP6dArL9lwJpAAAAAAEAZAAAAoACsgANAAAhIwEWFREjETMBJjUR +MwKAhP67A1aEAUUDVAJeeov+pwKy/aJ5jAFZAAAAAgAv//ICuwLAAAkAEwAAEiAWFRQGICY1NBIyN +jU0JiIGFRTbATSsrP7MrNrYenrYegLAxaKhxsahov47nIeIm5uIhwACAGQAAAJHArIADgAYAAABHg +EVFAYHBisBESMRMzITNjQnJisBETMyAZRUX2VOHzuAVtY7GlxcGDWIiDUCrgtnVlVpCgT+5gKy/rU +V1BUF/vgAAAACAC//zAK9AsAAEgAcAAAlFhcHJiMiBwYjIiY1NDYgFhUUJRQWMjY1NCYiBgI9PUMx +UDcfKh8omqysATSs/dR62Hp62HpICTg7NgkHxqGixcWitbWHnJyHiJubAAIAZAAAAlgCsgAXACMAA +CUWFyMmJyYnJisBESMRMzIXHgEVFAYHFiUzMjc+ATU0JyYrAQIqDCJfGQwNWhAhglbiOx9QXEY1Tv +6bhDATMj1lGSyMtYgtOXR0BwH+1wKyBApbU0BSESRAAgVAOGoQBAABADT/8gIoAsAAJQAAATIWFyM +uASMiBhUUFhceARUUBiMiJiczHgEzMjY1NCYnLgE1NDYBOmd2ClwGS0E6SUNRdW+HZnKKC1wPWkQ9 +Uk1cZGuEAsBwXUJHNjQ3OhIbZVZZbm5kREo+NT5DFRdYUFdrAAAAAAEAIgAAAmQCsgAHAAABIxEjE +SM1IQJk9lb2AkICbP2UAmxGAAEAXv/yAmQCsgAXAAABERQHDgEiJicmNREzERQXHgEyNjc2NRECZA +IIgfCBCAJWAgZYmlgGAgKy/k0qFFxzc1wUKgGz/lUrEkRQUEQSKwGrAAAAAAEAIAAAAnoCsgAGAAA +hIwMzGwEzAYJ07l3N1FwCsv2PAnEAAAEAGgAAA7ECsgAMAAABAyMLASMDMxsBMxsBA7HAcZyicrZi +kaB0nJkCsv1OAlP9rQKy/ZsCW/2kAmYAAAEAGQAAAm8CsgALAAAhCwEjEwMzGwEzAxMCCsrEY/bkY +re+Y/D6AST+3AFcAVb+5gEa/q3+oQAAAQATAAACUQKyAAgAAAERIxEDMxsBMwFdVvRjwLphARD+8A +EQAaL+sQFPAAABAC4AAAI5ArIACQAAJRUhNQEhNSEVAQI5/fUBof57Aen+YUZGQgIqRkX92QAAAAA +BAGL/sAEFAwwABwAAARUjETMVIxEBBWlpowMMOP0UOANcAAAB//v/4gE0AtAAAwAABSMDMwE0Pvs+ +HgLuAAAAAQAi/7AAxQMMAAcAABcjNTMRIzUzxaNpaaNQOALsOAABAFAA1wH0AmgABgAAJQsBIxMzE +wGwjY1GsESw1wFZ/qcBkf5vAAAAAQAy/6oBwv/iAAMAAAUhNSEBwv5wAZBWOAAAAAEAKQJEALYCsg +ADAAATIycztjhVUAJEbgAAAAACACT/8gHQAiAAHQAlAAAhJwcGIyImNTQ2OwE1NCcmIyIHIz4BMzI +XFh0BFBcnMjY9ASYVFAF6CR0wVUtgkJoiAgdgaQlaBm1Zrg4DCuQ9R+5MOSFQR1tbDiwUUXBUXowf +J8c9SjRORzYSgVwAAAAAAgBK//ICRQLfABEAHgAAATIWFRQGIyImLwEVIxEzETc2EzI2NTQmIyIGH +QEUFgFUcYCVbiNJEyNWVigySElcU01JXmECIJd4i5QTEDRJAt/+3jkq/hRuZV55ZWsdX14AAQAe// +IB9wIgABgAAAEyFhcjJiMiBhUUFjMyNjczDgEjIiY1NDYBF152DFocbEJXU0A1Rw1aE3pbaoKQAiB +oWH5qZm1tPDlaXYuLgZcAAAACAB7/8gIZAt8AEQAeAAABESM1BwYjIiY1NDYzMhYfAREDMjY9ATQm +IyIGFRQWAhlWKDJacYCVbiNJEyOnSV5hQUlcUwLf/SFVOSqXeIuUExA0ARb9VWVrHV9ebmVeeQACA +B7/8gH9AiAAFQAbAAABFAchHgEzMjY3Mw4BIyImNTQ2MzIWJyIGByEmAf0C/oAGUkA1SwlaD4FXbI +WObmt45UBVBwEqDQEYFhNjWD84W16Oh3+akU9aU60AAAEAFQAAARoC8gAWAAATBh0BMxUjESMRIzU +zNTQ3PgEzMhcVJqcDbW1WOTkDB0k8Hx5oAngVITRC/jQBzEIsJRs5PwVHEwAAAAIAHv8uAhkCIAAi +AC8AAAERFAcOASMiLwEzFx4BMzI2NzY9AQcGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZAQSEd +NwRAVcBBU5DTlUDASgyWnGAlW4jSRMjp0leYUFJXFMCEv5wSh1zeq8KCTI8VU0ZIQk5Kpd4i5QTED +RJ/iJlax1fXm5lXnkAAQBKAAACCgLkABcAAAEWFREjETQnLgEHDgEdASMRMxE3NjMyFgIIAlYCBDs +6RVRWViE5UVViAYUbQP7WASQxGzI7AQJyf+kC5P7TPSxUAAACAD4AAACsAsAABwALAAASMhYUBiIm +NBMjETNeLiAgLiBiVlYCwCAuICAu/WACEgAC//P/LgCnAsAABwAVAAASMhYUBiImNBcRFAcGIyInN +RY3NjURWS4gIC4gYgMLcRwNSgYCAsAgLiAgLo79wCUbZAJGBzMOHgJEAAAAAQBKAAACCALfAAsAAC +EnBxUjETMREzMHEwGTwTJWVvdu9/rgN6kC3/4oAQv6/ugAAQBG//wA3gLfAA8AABMRFBceATcVBiM +iJicmNRGcAQIcIxkkKi4CAQLf/bkhERoSBD4EJC8SNAJKAAAAAQBKAAADEAIgACQAAAEWFREjETQn +JiMiFREjETQnJiMiFREjETMVNzYzMhYXNzYzMhYDCwVWBAxedFYEDF50VlYiJko7ThAvJkpEVAGfI +jn+vAEcQyRZ1v76ARxDJFnW/voCEk08HzYtRB9HAAAAAAEASgAAAgoCIAAWAAABFhURIxE0JyYjIg +YdASMRMxU3NjMyFgIIAlYCCXBEVVZWITlRVWIBhRtA/tYBJDEbbHR/6QISWz0sVAAAAAACAB7/8gI +sAiAABwARAAASIBYUBiAmNBIyNjU0JiIGFRSlAQCHh/8Ah7ieWlqeWgIgn/Cfn/D+s3ZfYHV1YF8A +AgBK/zwCRQIgABEAHgAAATIWFRQGIyImLwERIxEzFTc2EzI2NTQmIyIGHQEUFgFUcYCVbiNJEyNWV +igySElcU01JXmECIJd4i5QTEDT+8wLWVTkq/hRuZV55ZWsdX14AAgAe/zwCGQIgABEAHgAAAREjEQ +cGIyImNTQ2MzIWHwE1AzI2PQE0JiMiBhUUFgIZVigyWnGAlW4jSRMjp0leYUFJXFMCEv0qARk5Kpd +4i5QTEDRJ/iJlax1fXm5lXnkAAQBKAAABPgIeAA0AAAEyFxUmBhURIxEzFTc2ARoWDkdXVlYwIwIe +B0EFVlf+0gISU0cYAAEAGP/yAa0CIAAjAAATMhYXIyYjIgYVFBYXHgEVFAYjIiYnMxYzMjY1NCYnL +gE1NDbkV2MJWhNdKy04PF1XbVhWbgxaE2ktOjlEUllkAiBaS2MrJCUoEBlPQkhOVFZoKCUmLhIWSE +BIUwAAAAEAFP/4ARQCiQAXAAATERQXHgE3FQYjIiYnJjURIzUzNTMVMxWxAQMmMx8qMjMEAUdHVmM +BzP7PGw4mFgY/BSwxDjQBNUJ7e0IAAAABAEL/8gICAhIAFwAAAREjNQcGIyImJyY1ETMRFBceATMy +Nj0BAgJWITlRT2EKBVYEBkA1RFECEv3uWj4qTToiOQE+/tIlJC43c4DpAAAAAAEAAQAAAfwCEgAGA +AABAyMDMxsBAfzJaclfop8CEv3uAhL+LQHTAAABAAEAAAMLAhIADAAAAQMjCwEjAzMbATMbAQMLqW +Z2dmapY3t0a3Z7AhL97gG+/kICEv5AAcD+QwG9AAAB//oAAAHWAhIACwAAARMjJwcjEwMzFzczARq +8ZIuKY763ZoWFYwEO/vLV1QEMAQbNzQAAAQAB/y4B+wISABEAAAEDDgEjIic1FjMyNj8BAzMbAQH7 +2iFZQB8NDRIpNhQH02GenQIS/cFVUAJGASozEwIt/i4B0gABABQAAAGxAg4ACQAAJRUhNQEhNSEVA +QGx/mMBNP7iAYL+zkREQgGIREX+ewAAAAABAED/sAEOAwwALAAAASMiBhUUFxYVFAYHHgEVFAcGFR +QWOwEVIyImNTQ3NjU0JzU2NTQnJjU0NjsBAQ4MKiMLDS4pKS4NCyMqDAtERAwLUlILDERECwLUGBk +WTlsgKzUFBTcrIFtOFhkYOC87GFVMIkUIOAhFIkxVGDsvAAAAAAEAYP84AJoDIAADAAAXIxEzmjo6 +yAPoAAEAIf+wAO8DDAAsAAATFQYVFBcWFRQGKwE1MzI2NTQnJjU0NjcuATU0NzY1NCYrATUzMhYVF +AcGFRTvUgsMREQLDCojCw0uKSkuDQsjKgwLREQMCwF6OAhFIkxVGDsvOBgZFk5bICs1BQU3KyBbTh +YZGDgvOxhVTCJFAAABAE0A3wH2AWQAEwAAATMUIyImJyYjIhUjNDMyFhcWMzIBvjhuGywtQR0xOG4 +bLC1BHTEBZIURGCNMhREYIwAAAwAk/94DIgLoAAcAEQApAAAAIBYQBiAmECQgBhUUFiA2NTQlMhYX +IyYjIgYUFjMyNjczDgEjIiY1NDYBAQFE3d3+vN0CB/7wubkBELn+xVBnD1wSWDo+QTcqOQZcEmZWX +HN2Aujg/rbg4AFKpr+Mjb6+jYxbWEldV5ZZNShLVn5na34AAgB4AFIB9AGeAAUACwAAAQcXIyc3Mw +cXIyc3AUqJiUmJifOJiUmJiQGepqampqampqYAAAIAHAHSAQ4CwAAHAA8AABIyFhQGIiY0NiIGFBY +yNjRgakREakSTNCEhNCECwEJqQkJqCiM4IyM4AAAAAAIAUAAAAfQCCwALAA8AAAEzFSMVIzUjNTM1 +MxMhNSEBP7W1OrW1OrX+XAGkAVs4tLQ4sP31OAAAAQB0AkQBAQKyAAMAABMjNzOsOD1QAkRuAAAAA +AEAIADsAKoBdgAHAAASMhYUBiImNEg6KCg6KAF2KDooKDoAAAIAOQBSAbUBngAFAAsAACUHIzcnMw +UHIzcnMwELiUmJiUkBM4lJiYlJ+KampqampqYAAAABADYB5QDhAt8ABAAAEzczByM2Xk1OXQHv8Po +AAQAWAeUAwQLfAAQAABMHIzczwV5NTl0C1fD6AAIANgHlAYsC3wAEAAkAABM3MwcjPwEzByM2Xk1O +XapeTU5dAe/w+grw+gAAAgAWAeUBawLfAAQACQAAEwcjNzMXByM3M8FeTU5dql5NTl0C1fD6CvD6A +AADACX/8gI1AHIABwAPABcAADYyFhQGIiY0NjIWFAYiJjQ2MhYUBiImNEk4JCQ4JOw4JCQ4JOw4JC +Q4JHIkOCQkOCQkOCQkOCQkOCQkOAAAAAEAeABSAUoBngAFAAABBxcjJzcBSomJSYmJAZ6mpqamAAA +AAAEAOQBSAQsBngAFAAAlByM3JzMBC4lJiYlJ+KampgAAAf9qAAABgQKyAAMAACsBATM/VwHAVwKy +AAAAAAIAFAHIAdwClAAHABQAABMVIxUjNSM1BRUjNwcjJxcjNTMXN9pKMkoByDICKzQqATJLKysCl +CmjoykBy46KiY3Lm5sAAQAVAAABvALyABgAAAERIxEjESMRIzUzNTQ3NjMyFxUmBgcGHQEBvFbCVj +k5AxHHHx5iVgcDAg798gHM/jQBzEIOJRuWBUcIJDAVIRYAAAABABX//AHkAvIAJQAAJR4BNxUGIyI +mJyY1ESYjIgcGHQEzFSMRIxEjNTM1NDc2MzIXERQBowIcIxkkKi4CAR4nXgwDbW1WLy8DEbNdOmYa +EQQ/BCQvEjQCFQZWFSEWQv40AcxCDiUblhP9uSEAAAAAAAAWAQ4AAQAAAAAAAAATACgAAQAAAAAAA +QAHAEwAAQAAAAAAAgAHAGQAAQAAAAAAAwAaAKIAAQAAAAAABAAHAM0AAQAAAAAABQA8AU8AAQAAAA +AABgAPAawAAQAAAAAACAALAdQAAQAAAAAACQALAfgAAQAAAAAACwAXAjQAAQAAAAAADAAXAnwAAwA +BBAkAAAAmAAAAAwABBAkAAQAOADwAAwABBAkAAgAOAFQAAwABBAkAAwA0AGwAAwABBAkABAAOAL0A +AwABBAkABQB4ANUAAwABBAkABgAeAYwAAwABBAkACAAWAbwAAwABBAkACQAWAeAAAwABBAkACwAuA +gQAAwABBAkADAAuAkwATgBvACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAATm8gUm +lnaHRzIFJlc2VydmVkLgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAUgBlAGcAdQBsAGEAcgAAUmV +ndWxhcgAAMQAuADEAMAAyADsAVQBLAFcATgA7AEEAaQBsAGUAcgBvAG4ALQBSAGUAZwB1AGwAYQBy +AAAxLjEwMjtVS1dOO0FpbGVyb24tUmVndWxhcgAAQQBpAGwAZQByAG8AbgAAQWlsZXJvbgAAVgBlA +HIAcwBpAG8AbgAgADEALgAxADAAMgA7AFAAUwAgADAAMAAxAC4AMQAwADIAOwBoAG8AdABjAG8Abg +B2ACAAMQAuADAALgA3ADAAOwBtAGEAawBlAG8AdABmAC4AbABpAGIAMgAuADUALgA1ADgAMwAyADk +AAFZlcnNpb24gMS4xMDI7UFMgMDAxLjEwMjtob3Rjb252IDEuMC43MDttYWtlb3RmLmxpYjIuNS41 +ODMyOQAAQQBpAGwAZQByAG8AbgAtAFIAZQBnAHUAbABhAHIAAEFpbGVyb24tUmVndWxhcgAAUwBvA +HIAYQAgAFMAYQBnAGEAbgBvAABTb3JhIFNhZ2FubwAAUwBvAHIAYQAgAFMAYQBnAGEAbgBvAABTb3 +JhIFNhZ2FubwAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBsAG8AbgAuAG4AZQB0AAB +odHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAaAB0AHQAcAA6AC8ALwB3AHcAdwAuAGQAbwB0AGMAbwBs +AG8AbgAuAG4AZQB0AABodHRwOi8vd3d3LmRvdGNvbG9uLm5ldAAAAAACAAAAAAAA/4MAMgAAAAAAA +AAAAAAAAAAAAAAAAAAAAHQAAAABAAIAAwAEAAUABgAHAAgACQAKAAsADAANAA4ADwAQABEAEgATAB +QAFQAWABcAGAAZABoAGwAcAB0AHgAfACAAIQAiACMAJAAlACYAJwAoACkAKgArACwALQAuAC8AMAA +xADIAMwA0ADUANgA3ADgAOQA6ADsAPAA9AD4APwBAAEEAQgBDAEQARQBGAEcASABJAEoASwBMAE0A +TgBPAFAAUQBSAFMAVABVAFYAVwBYAFkAWgBbAFwAXQBeAF8AYABhAIsAqQCDAJMAjQDDAKoAtgC3A +LQAtQCrAL4AvwC8AIwAwADBAAAAAAAB//8AAgABAAAADAAAABwAAAACAAIAAwBxAAEAcgBzAAIABA +AAAAIAAAABAAAACgBMAGYAAkRGTFQADmxhdG4AGgAEAAAAAP//AAEAAAAWAANDQVQgAB5NT0wgABZ +ST00gABYAAP//AAEAAAAA//8AAgAAAAEAAmxpZ2EADmxvY2wAFAAAAAEAAQAAAAEAAAACAAYAEAAG +AAAAAgASADQABAAAAAEATAADAAAAAgAQABYAAQAcAAAAAQABAE8AAQABAGcAAQABAE8AAwAAAAIAE +AAWAAEAHAAAAAEAAQAvAAEAAQBnAAEAAQAvAAEAGgABAAgAAgAGAAwAcwACAE8AcgACAEwAAQABAE +kAAAABAAAACgBGAGAAAkRGTFQADmxhdG4AHAAEAAAAAP//AAIAAAABABYAA0NBVCAAFk1PTCAAFlJ +PTSAAFgAA//8AAgAAAAEAAmNwc3AADmtlcm4AFAAAAAEAAAAAAAEAAQACAAYADgABAAAAAQASAAIA +AAACAB4ANgABAAoABQAFAAoAAgABACQAPQAAAAEAEgAEAAAAAQAMAAEAOP/nAAEAAQAkAAIGigAEA +AAFJAXKABoAGQAA//gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAD/sv+4/+z/7v/MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAD/xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9T/6AAAAAD/8QAA +ABD/vQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/7gAAAAAAAAAAAAAAAAAA//MAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIAAAAAAAAAAP/5AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/gAAD/4AAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//L/9AAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAA/+gAAAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/zAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/mAAAAAAAAAAAAAAAAAAD +/4gAA//AAAAAA//YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+AAAAAAAAP/OAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/zv/qAAAAAP/0AAAACAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/ZAAD/egAA/1kAAAAA/5D/rgAAAAAAAAAAAA +AAAAAAAAAAAAAAAAD/9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAD/8AAA/7b/8P+wAAD/8P/E/98AAAAA/8P/+P/0//oAAAAAAAAAAAAA//gA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/+AAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/w//C/9MAAP/SAAD/9wAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAD/yAAA/+kAAAAA//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/9wAAAAD//QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAP/2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAP/cAAAAAAAAAAAAAAAA/7YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAP/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/6AAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAkAFAAEAAAAAQACwAAABcA +BgAAAAAAAAAIAA4AAAAAAAsAEgAAAAAAAAATABkAAwANAAAAAQAJAAAAAAAAAAAAAAAAAAAAGAAAA +AAABwAAAAAAAAAAAAAAFQAFAAAAAAAYABgAAAAUAAAACgAAAAwAAgAPABEAFgAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAEAEQBdAAYAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAcAAAAAAAAABwAAAAAACAAAAAAAAAAAAAcAAAAHAAAAEwAJ +ABUADgAPAAAACwAQAAAAAAAAAAAAAAAAAAUAGAACAAIAAgAAAAIAGAAXAAAAGAAAABYAFgACABYAA +gAWAAAAEQADAAoAFAAMAA0ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAEgAGAAEAHgAkAC +YAJwApACoALQAuAC8AMgAzADcAOAA5ADoAPAA9AEUASABOAE8AUgBTAFUAVwBZAFoAWwBcAF0AcwA +AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ== +""" + ) + ), + 10 if size is None else size, + layout_engine=Layout.BASIC, + ) + return load_default_imagefont() diff --git a/dependencies/WIN/PIL/ImageGrab.py b/dependencies/WIN/PIL/ImageGrab.py index b93ec3f..fe27bfa 100644 --- a/dependencies/WIN/PIL/ImageGrab.py +++ b/dependencies/WIN/PIL/ImageGrab.py @@ -14,30 +14,41 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import io +import os +import shutil +import subprocess import sys +import tempfile from . import Image -if sys.platform == "darwin": - import os - import subprocess - import tempfile - -def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): +def grab( + bbox: tuple[int, int, int, int] | None = None, + include_layered_windows: bool = False, + all_screens: bool = False, + xdisplay: str | None = None, +) -> Image.Image: + im: Image.Image if xdisplay is None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") os.close(fh) - subprocess.call(["screencapture", "-x", filepath]) + args = ["screencapture"] + if bbox: + left, top, right, bottom = bbox + args += ["-R", f"{left},{top},{right-left},{bottom-top}"] + subprocess.call(args + ["-x", filepath]) im = Image.open(filepath) im.load() os.unlink(filepath) if bbox: - im_cropped = im.crop(bbox) + im_resized = im.resize((right - left, bottom - top)) im.close() - return im_cropped + return im_resized return im elif sys.platform == "win32": offset, size, data = Image.core.grabscreen_win32( @@ -58,40 +69,52 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im - # use xdisplay=None for default display on non-win32/macOS systems - if not Image.core.HAVE_XCB: - raise OSError("Pillow was built without XCB support") - size, data = Image.core.grabscreen_x11(xdisplay) - im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) - if bbox: - im = im.crop(bbox) - return im + # Cast to Optional[str] needed for Windows and macOS. + display_name: str | None = xdisplay + try: + if not Image.core.HAVE_XCB: + msg = "Pillow was built without XCB support" + raise OSError(msg) + size, data = Image.core.grabscreen_x11(display_name) + except OSError: + if ( + display_name is None + and sys.platform not in ("darwin", "win32") + and shutil.which("gnome-screenshot") + ): + fh, filepath = tempfile.mkstemp(".png") + os.close(fh) + subprocess.call(["gnome-screenshot", "-f", filepath]) + im = Image.open(filepath) + im.load() + os.unlink(filepath) + if bbox: + im_cropped = im.crop(bbox) + im.close() + return im_cropped + return im + else: + raise + else: + im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1) + if bbox: + im = im.crop(bbox) + return im -def grabclipboard(): +def grabclipboard() -> Image.Image | list[str] | None: if sys.platform == "darwin": - fh, filepath = tempfile.mkstemp(".jpg") - os.close(fh) - commands = [ - 'set theFile to (open for access POSIX file "' - + filepath - + '" with write permission)', - "try", - " write (the clipboard as JPEG picture) to theFile", - "end try", - "close access theFile", - ] - script = ["osascript"] - for command in commands: - script += ["-e", command] - subprocess.call(script) + p = subprocess.run( + ["osascript", "-e", "get the clipboard as «class PNGf»"], + capture_output=True, + ) + if p.returncode != 0: + return None - im = None - if os.stat(filepath).st_size != 0: - im = Image.open(filepath) - im.load() - os.unlink(filepath) - return im + import binascii + + data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3])) + return Image.open(data) elif sys.platform == "win32": fmt, data = Image.core.grabclipboard_win32() if fmt == "file": # CF_HDROP @@ -104,8 +127,6 @@ def grabclipboard(): files = data[o:].decode("mbcs").split("\0") return files[: files.index("")] if isinstance(data, bytes): - import io - data = io.BytesIO(data) if fmt == "png": from . import PngImagePlugin @@ -117,4 +138,46 @@ def grabclipboard(): return BmpImagePlugin.DibImageFile(data) return None else: - raise NotImplementedError("ImageGrab.grabclipboard() is macOS and Windows only") + if os.getenv("WAYLAND_DISPLAY"): + session_type = "wayland" + elif os.getenv("DISPLAY"): + session_type = "x11" + else: # Session type check failed + session_type = None + + if shutil.which("wl-paste") and session_type in ("wayland", None): + args = ["wl-paste", "-t", "image"] + elif shutil.which("xclip") and session_type in ("x11", None): + args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] + else: + msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux" + raise NotImplementedError(msg) + + p = subprocess.run(args, capture_output=True) + if p.returncode != 0: + err = p.stderr + for silent_error in [ + # wl-paste, when the clipboard is empty + b"Nothing is copied", + # Ubuntu/Debian wl-paste, when the clipboard is empty + b"No selection", + # Ubuntu/Debian wl-paste, when an image isn't available + b"No suitable type of content copied", + # wl-paste or Ubuntu/Debian xclip, when an image isn't available + b" not available", + # xclip, when an image isn't available + b"cannot convert ", + # xclip, when the clipboard isn't initialized + b"xclip: Error: There is no owner for the ", + ]: + if silent_error in err: + return None + msg = f"{args[0]} error" + if err: + msg += f": {err.strip().decode()}" + raise ChildProcessError(msg) + + data = io.BytesIO(p.stdout) + im = Image.open(data) + im.load() + return im diff --git a/dependencies/WIN/PIL/ImageMath.py b/dependencies/WIN/PIL/ImageMath.py index 7f9c88e..484797f 100644 --- a/dependencies/WIN/PIL/ImageMath.py +++ b/dependencies/WIN/PIL/ImageMath.py @@ -14,25 +14,23 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import builtins +from types import CodeType +from typing import Any, Callable from . import Image, _imagingmath - -VERBOSE = 0 - - -def _isconstant(v): - return isinstance(v, (int, float)) +from ._deprecate import deprecate class _Operand: """Wraps an image operand, providing standard operators""" - def __init__(self, im): + def __init__(self, im: Image.Image): self.im = im - def __fixup(self, im1): + def __fixup(self, im1: _Operand | float) -> Image.Image: # convert image to suitable mode if isinstance(im1, _Operand): # argument was an image. @@ -41,213 +39,330 @@ def __fixup(self, im1): elif im1.im.mode in ("I", "F"): return im1.im else: - raise ValueError(f"unsupported mode: {im1.im.mode}") + msg = f"unsupported mode: {im1.im.mode}" + raise ValueError(msg) else: # argument was a constant - if _isconstant(im1) and self.im.mode in ("1", "L", "I"): + if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"): return Image.new("I", self.im.size, im1) else: return Image.new("F", self.im.size, im1) - def apply(self, op, im1, im2=None, mode=None): - im1 = self.__fixup(im1) + def apply( + self, + op: str, + im1: _Operand | float, + im2: _Operand | float | None = None, + mode: str | None = None, + ) -> _Operand: + im_1 = self.__fixup(im1) if im2 is None: # unary operation - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() + out = Image.new(mode or im_1.mode, im_1.size, None) try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e - _imagingmath.unop(op, out.im.id, im1.im.id) + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.unop(op, out.getim(), im_1.getim()) else: # binary operation - im2 = self.__fixup(im2) - if im1.mode != im2.mode: + im_2 = self.__fixup(im2) + if im_1.mode != im_2.mode: # convert both arguments to floating point - if im1.mode != "F": - im1 = im1.convert("F") - if im2.mode != "F": - im2 = im2.convert("F") - if im1.mode != im2.mode: - raise ValueError("mode mismatch") - if im1.size != im2.size: + if im_1.mode != "F": + im_1 = im_1.convert("F") + if im_2.mode != "F": + im_2 = im_2.convert("F") + if im_1.size != im_2.size: # crop both arguments to a common size - size = (min(im1.size[0], im2.size[0]), min(im1.size[1], im2.size[1])) - if im1.size != size: - im1 = im1.crop((0, 0) + size) - if im2.size != size: - im2 = im2.crop((0, 0) + size) - out = Image.new(mode or im1.mode, size, None) - else: - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() - im2.load() + size = ( + min(im_1.size[0], im_2.size[0]), + min(im_1.size[1], im_2.size[1]), + ) + if im_1.size != size: + im_1 = im_1.crop((0, 0) + size) + if im_2.size != size: + im_2 = im_2.crop((0, 0) + size) + out = Image.new(mode or im_1.mode, im_1.size, None) try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, f"{op}_{im_1.mode}") except AttributeError as e: - raise TypeError(f"bad operand type for '{op}'") from e - _imagingmath.binop(op, out.im.id, im1.im.id, im2.im.id) + msg = f"bad operand type for '{op}'" + raise TypeError(msg) from e + _imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim()) return _Operand(out) # unary operators - def __bool__(self): + def __bool__(self) -> bool: # an image is "true" if it contains at least one non-zero pixel return self.im.getbbox() is not None - def __abs__(self): + def __abs__(self) -> _Operand: return self.apply("abs", self) - def __pos__(self): + def __pos__(self) -> _Operand: return self - def __neg__(self): + def __neg__(self) -> _Operand: return self.apply("neg", self) # binary operators - def __add__(self, other): + def __add__(self, other: _Operand | float) -> _Operand: return self.apply("add", self, other) - def __radd__(self, other): + def __radd__(self, other: _Operand | float) -> _Operand: return self.apply("add", other, self) - def __sub__(self, other): + def __sub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", self, other) - def __rsub__(self, other): + def __rsub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", other, self) - def __mul__(self, other): + def __mul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", self, other) - def __rmul__(self, other): + def __rmul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", other, self) - def __truediv__(self, other): + def __truediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", self, other) - def __rtruediv__(self, other): + def __rtruediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", other, self) - def __mod__(self, other): + def __mod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", self, other) - def __rmod__(self, other): + def __rmod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", other, self) - def __pow__(self, other): + def __pow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", self, other) - def __rpow__(self, other): + def __rpow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", other, self) # bitwise - def __invert__(self): + def __invert__(self) -> _Operand: return self.apply("invert", self) - def __and__(self, other): + def __and__(self, other: _Operand | float) -> _Operand: return self.apply("and", self, other) - def __rand__(self, other): + def __rand__(self, other: _Operand | float) -> _Operand: return self.apply("and", other, self) - def __or__(self, other): + def __or__(self, other: _Operand | float) -> _Operand: return self.apply("or", self, other) - def __ror__(self, other): + def __ror__(self, other: _Operand | float) -> _Operand: return self.apply("or", other, self) - def __xor__(self, other): + def __xor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", self, other) - def __rxor__(self, other): + def __rxor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", other, self) - def __lshift__(self, other): + def __lshift__(self, other: _Operand | float) -> _Operand: return self.apply("lshift", self, other) - def __rshift__(self, other): + def __rshift__(self, other: _Operand | float) -> _Operand: return self.apply("rshift", self, other) # logical - def __eq__(self, other): + def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("eq", self, other) - def __ne__(self, other): + def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override] return self.apply("ne", self, other) - def __lt__(self, other): + def __lt__(self, other: _Operand | float) -> _Operand: return self.apply("lt", self, other) - def __le__(self, other): + def __le__(self, other: _Operand | float) -> _Operand: return self.apply("le", self, other) - def __gt__(self, other): + def __gt__(self, other: _Operand | float) -> _Operand: return self.apply("gt", self, other) - def __ge__(self, other): + def __ge__(self, other: _Operand | float) -> _Operand: return self.apply("ge", self, other) # conversions -def imagemath_int(self): +def imagemath_int(self: _Operand) -> _Operand: return _Operand(self.im.convert("I")) -def imagemath_float(self): +def imagemath_float(self: _Operand) -> _Operand: return _Operand(self.im.convert("F")) # logical -def imagemath_equal(self, other): +def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("eq", self, other, mode="I") -def imagemath_notequal(self, other): +def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("ne", self, other, mode="I") -def imagemath_min(self, other): +def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("min", self, other) -def imagemath_max(self, other): +def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("max", self, other) -def imagemath_convert(self, mode): +def imagemath_convert(self: _Operand, mode: str) -> _Operand: return _Operand(self.im.convert(mode)) -ops = {} -for k, v in list(globals().items()): - if k[:10] == "imagemath_": - ops[k[10:]] = v +ops = { + "int": imagemath_int, + "float": imagemath_float, + "equal": imagemath_equal, + "notequal": imagemath_notequal, + "min": imagemath_min, + "max": imagemath_max, + "convert": imagemath_convert, +} -def eval(expression, _dict={}, **kw): +def lambda_eval( + expression: Callable[[dict[str, Any]], Any], + options: dict[str, Any] = {}, + **kw: Any, +) -> Any: """ - Evaluates an image expression. + Returns the result of an image function. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. + + :param expression: A function that receives a dictionary. + :param options: Values to add to the function's dictionary. Deprecated. + You can instead use one or more keyword arguments. + :param **kw: Values to add to the function's dictionary. + :return: The expression result. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + """ + + if options: + deprecate( + "ImageMath.lambda_eval options", + 12, + "ImageMath.lambda_eval keyword arguments", + ) + + args: dict[str, Any] = ops.copy() + args.update(options) + args.update(kw) + for k, v in args.items(): + if isinstance(v, Image.Image): + args[k] = _Operand(v) + + out = expression(args) + try: + return out.im + except AttributeError: + return out + + +def unsafe_eval( + expression: str, + options: dict[str, Any] = {}, + **kw: Any, +) -> Any: + """ + Evaluates an image expression. This uses Python's ``eval()`` function to process + the expression string, and carries the security risks of doing so. It is not + recommended to process expressions without considering this. + :py:meth:`~lambda_eval` is a more secure alternative. + + :py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band + images, use the :py:meth:`~PIL.Image.Image.split` method or + :py:func:`~PIL.Image.merge` function. :param expression: A string containing a Python-style expression. - :param options: Values to add to the evaluation context. You - can either use a dictionary, or one or more keyword - arguments. + :param options: Values to add to the evaluation context. Deprecated. + You can instead use one or more keyword arguments. + :param **kw: Values to add to the evaluation context. :return: The evaluated expression. This is usually an image object, but can also be an integer, a floating point value, or a pixel tuple, depending on the expression. """ + if options: + deprecate( + "ImageMath.unsafe_eval options", + 12, + "ImageMath.unsafe_eval keyword arguments", + ) + # build execution namespace - args = ops.copy() - args.update(_dict) + args: dict[str, Any] = ops.copy() + for k in list(options.keys()) + list(kw.keys()): + if "__" in k or hasattr(builtins, k): + msg = f"'{k}' not allowed" + raise ValueError(msg) + + args.update(options) args.update(kw) - for k, v in list(args.items()): - if hasattr(v, "im"): + for k, v in args.items(): + if isinstance(v, Image.Image): args[k] = _Operand(v) - out = builtins.eval(expression, args) + compiled_code = compile(expression, "", "eval") + + def scan(code: CodeType) -> None: + for const in code.co_consts: + if type(const) is type(compiled_code): + scan(const) + + for name in code.co_names: + if name not in args and name != "abs": + msg = f"'{name}' not allowed" + raise ValueError(msg) + + scan(compiled_code) + out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args) try: return out.im except AttributeError: return out + + +def eval( + expression: str, + _dict: dict[str, Any] = {}, + **kw: Any, +) -> Any: + """ + Evaluates an image expression. + + Deprecated. Use lambda_eval() or unsafe_eval() instead. + + :param expression: A string containing a Python-style expression. + :param _dict: Values to add to the evaluation context. You + can either use a dictionary, or one or more keyword + arguments. + :return: The evaluated expression. This is usually an image object, but can + also be an integer, a floating point value, or a pixel tuple, + depending on the expression. + + .. deprecated:: 10.3.0 + """ + + deprecate( + "ImageMath.eval", + 12, + "ImageMath.lambda_eval or ImageMath.unsafe_eval", + ) + return unsafe_eval(expression, _dict, **kw) diff --git a/dependencies/WIN/PIL/ImageMode.py b/dependencies/WIN/PIL/ImageMode.py index 0afcf9f..92a08d2 100644 --- a/dependencies/WIN/PIL/ImageMode.py +++ b/dependencies/WIN/PIL/ImageMode.py @@ -12,63 +12,81 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations -# mode descriptor cache -_modes = None +import sys +from functools import lru_cache +from typing import NamedTuple +from ._deprecate import deprecate -class ModeDescriptor: + +class ModeDescriptor(NamedTuple): """Wrapper for mode strings.""" - def __init__(self, mode, bands, basemode, basetype): - self.mode = mode - self.bands = bands - self.basemode = basemode - self.basetype = basetype + mode: str + bands: tuple[str, ...] + basemode: str + basetype: str + typestr: str - def __str__(self): + def __str__(self) -> str: return self.mode -def getmode(mode): +@lru_cache +def getmode(mode: str) -> ModeDescriptor: """Gets a mode descriptor for the given mode.""" - global _modes - if not _modes: - # initialize mode cache - modes = {} - for m, (basemode, basetype, bands) in { - # core modes - "1": ("L", "L", ("1",)), - "L": ("L", "L", ("L",)), - "I": ("L", "I", ("I",)), - "F": ("L", "F", ("F",)), - "P": ("P", "L", ("P",)), - "RGB": ("RGB", "L", ("R", "G", "B")), - "RGBX": ("RGB", "L", ("R", "G", "B", "X")), - "RGBA": ("RGB", "L", ("R", "G", "B", "A")), - "CMYK": ("RGB", "L", ("C", "M", "Y", "K")), - "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr")), - "LAB": ("RGB", "L", ("L", "A", "B")), - "HSV": ("RGB", "L", ("H", "S", "V")), - # extra experimental modes - "RGBa": ("RGB", "L", ("R", "G", "B", "a")), - "LA": ("L", "L", ("L", "A")), - "La": ("L", "L", ("L", "a")), - "PA": ("RGB", "L", ("P", "A")), - }.items(): - modes[m] = ModeDescriptor(m, bands, basemode, basetype) - # mapping modes - for i16mode in ( - "I;16", - "I;16S", - "I;16L", - "I;16LS", - "I;16B", - "I;16BS", - "I;16N", - "I;16NS", - ): - modes[i16mode] = ModeDescriptor(i16mode, ("I",), "L", "L") - # set global mode cache atomically - _modes = modes - return _modes[mode] + endian = "<" if sys.byteorder == "little" else ">" + + modes = { + # core modes + # Bits need to be extended to bytes + "1": ("L", "L", ("1",), "|b1"), + "L": ("L", "L", ("L",), "|u1"), + "I": ("L", "I", ("I",), f"{endian}i4"), + "F": ("L", "F", ("F",), f"{endian}f4"), + "P": ("P", "L", ("P",), "|u1"), + "RGB": ("RGB", "L", ("R", "G", "B"), "|u1"), + "RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"), + "RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"), + "CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"), + "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"), + # UNDONE - unsigned |u1i1i1 + "LAB": ("RGB", "L", ("L", "A", "B"), "|u1"), + "HSV": ("RGB", "L", ("H", "S", "V"), "|u1"), + # extra experimental modes + "RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"), + "BGR;15": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;16": ("RGB", "L", ("B", "G", "R"), "|u1"), + "BGR;24": ("RGB", "L", ("B", "G", "R"), "|u1"), + "LA": ("L", "L", ("L", "A"), "|u1"), + "La": ("L", "L", ("L", "a"), "|u1"), + "PA": ("RGB", "L", ("P", "A"), "|u1"), + } + if mode in modes: + if mode in ("BGR;15", "BGR;16", "BGR;24"): + deprecate(mode, 12) + base_mode, base_type, bands, type_str = modes[mode] + return ModeDescriptor(mode, bands, base_mode, base_type, type_str) + + mapping_modes = { + # I;16 == I;16L, and I;32 == I;32L + "I;16": "u2", + "I;16BS": ">i2", + "I;16N": f"{endian}u2", + "I;16NS": f"{endian}i2", + "I;32": "u4", + "I;32L": "i4", + "I;32LS": " +from __future__ import annotations import re @@ -61,12 +62,14 @@ class LutBuilder: """ - def __init__(self, patterns=None, op_name=None): + def __init__( + self, patterns: list[str] | None = None, op_name: str | None = None + ) -> None: if patterns is not None: self.patterns = patterns else: self.patterns = [] - self.lut = None + self.lut: bytearray | None = None if op_name is not None: known_patterns = { "corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"], @@ -81,29 +84,32 @@ def __init__(self, patterns=None, op_name=None): ], } if op_name not in known_patterns: - raise Exception("Unknown pattern " + op_name + "!") + msg = f"Unknown pattern {op_name}!" + raise Exception(msg) self.patterns = known_patterns[op_name] - def add_patterns(self, patterns): + def add_patterns(self, patterns: list[str]) -> None: self.patterns += patterns - def build_default_lut(self): + def build_default_lut(self) -> None: symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) - def get_lut(self): + def get_lut(self) -> bytearray | None: return self.lut - def _string_permute(self, pattern, permutation): + def _string_permute(self, pattern: str, permutation: list[int]) -> str: """string_permute takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 return "".join(pattern[p] for p in permutation) - def _pattern_permute(self, basic_pattern, options, basic_result): + def _pattern_permute( + self, basic_pattern: str, options: str, basic_result: int + ) -> list[tuple[str, int]]: """pattern_permute takes a basic pattern and its result and clones the pattern according to the modifications described in the $options parameter. It returns a list of all cloned patterns.""" @@ -119,13 +125,13 @@ def _pattern_permute(self, basic_pattern, options, basic_result): # mirror if "M" in options: n = len(patterns) - for pattern, res in patterns[0:n]: + for pattern, res in patterns[:n]: patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res)) # negate if "N" in options: n = len(patterns) - for pattern, res in patterns[0:n]: + for pattern, res in patterns[:n]: # Swap 0 and 1 pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1") res = 1 - int(res) @@ -133,19 +139,21 @@ def _pattern_permute(self, basic_pattern, options, basic_result): return patterns - def build_lut(self): + def build_lut(self) -> bytearray: """Compile all patterns into a morphology lut. TBD :Build based on (file) morphlut:modify_lut """ self.build_default_lut() + assert self.lut is not None patterns = [] # Parse and create symmetries of the patterns strings for p in self.patterns: m = re.search(r"(\w*):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", "")) if not m: - raise Exception('Syntax error in pattern "' + p + '"') + msg = 'Syntax error in pattern "' + p + '"' + raise Exception(msg) options = m.group(1) pattern = m.group(2) result = int(m.group(3)) @@ -156,10 +164,10 @@ def build_lut(self): patterns += self._pattern_permute(pattern, options, result) # compile the patterns into regular expressions for speed - for i, pattern in enumerate(patterns): + compiled_patterns = [] + for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") - p = re.compile(p) - patterns[i] = (p, pattern[1]) + compiled_patterns.append((re.compile(p), pattern[1])) # Step through table and find patterns that match. # Note that all the patterns are searched. The last one @@ -169,8 +177,8 @@ def build_lut(self): bitpattern = bin(i)[2:] bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1] - for p, r in patterns: - if p.match(bitpattern): + for pattern, r in compiled_patterns: + if pattern.match(bitpattern): self.lut[i] = [0, 1][r] return self.lut @@ -179,7 +187,12 @@ def build_lut(self): class MorphOp: """A class for binary morphological operators""" - def __init__(self, lut=None, op_name=None, patterns=None): + def __init__( + self, + lut: bytearray | None = None, + op_name: str | None = None, + patterns: list[str] | None = None, + ) -> None: """Create a binary morphological operator""" self.lut = lut if op_name is not None: @@ -187,59 +200,66 @@ def __init__(self, lut=None, op_name=None, patterns=None): elif patterns is not None: self.lut = LutBuilder(patterns=patterns).build_lut() - def apply(self, image): + def apply(self, image: Image.Image) -> tuple[int, Image.Image]: """Run a single morphological operation on an image Returns a tuple of the number of changed pixels and the morphed image""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise ValueError("Image mode must be L") + msg = "Image mode must be L" + raise ValueError(msg) outimage = Image.new(image.mode, image.size, None) - count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) + count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage - def match(self, image): + def match(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of coordinates matching the morphological operation on an image. Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) if image.mode != "L": - raise ValueError("Image mode must be L") - return _imagingmorph.match(bytes(self.lut), image.im.id) + msg = "Image mode must be L" + raise ValueError(msg) + return _imagingmorph.match(bytes(self.lut), image.getim()) - def get_on_pixels(self, image): + def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: """Get a list of all turned on pixels in a binary image Returns a list of tuples of (x,y) coordinates of all matching pixels. See :ref:`coordinate-system`.""" if image.mode != "L": - raise ValueError("Image mode must be L") - return _imagingmorph.get_on_pixels(image.im.id) + msg = "Image mode must be L" + raise ValueError(msg) + return _imagingmorph.get_on_pixels(image.getim()) - def load_lut(self, filename): + def load_lut(self, filename: str) -> None: """Load an operator from an mrl file""" with open(filename, "rb") as f: self.lut = bytearray(f.read()) if len(self.lut) != LUT_SIZE: self.lut = None - raise Exception("Wrong size operator file!") + msg = "Wrong size operator file!" + raise Exception(msg) - def save_lut(self, filename): + def save_lut(self, filename: str) -> None: """Save an operator to an mrl file""" if self.lut is None: - raise Exception("No operator loaded") + msg = "No operator loaded" + raise Exception(msg) with open(filename, "wb") as f: f.write(self.lut) - def set_lut(self, lut): + def set_lut(self, lut: bytearray | None) -> None: """Set the lut from an external source""" self.lut = lut diff --git a/dependencies/WIN/PIL/ImageOps.py b/dependencies/WIN/PIL/ImageOps.py index f0c932d..bb29cc0 100644 --- a/dependencies/WIN/PIL/ImageOps.py +++ b/dependencies/WIN/PIL/ImageOps.py @@ -16,18 +16,21 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import functools import operator import re +from collections.abc import Sequence +from typing import Protocol, cast -from . import Image +from . import ExifTags, Image, ImagePalette # # helpers -def _border(border): +def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: if isinstance(border, tuple): if len(border) == 2: left, top = right, bottom = border @@ -38,7 +41,7 @@ def _border(border): return left, top, right, bottom -def _color(color, mode): +def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]: if isinstance(color, str): from . import ImageColor @@ -46,23 +49,31 @@ def _color(color, mode): return color -def _lut(image, lut): +def _lut(image: Image.Image, lut: list[int]) -> Image.Image: if image.mode == "P": # FIXME: apply to lookup table, not image data - raise NotImplementedError("mode P support coming soon") + msg = "mode P support coming soon" + raise NotImplementedError(msg) elif image.mode in ("L", "RGB"): if image.mode == "RGB" and len(lut) == 256: lut = lut + lut + lut return image.point(lut) else: - raise OSError("not supported for this image mode") + msg = f"not supported for mode {image.mode}" + raise OSError(msg) # # actions -def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): +def autocontrast( + image: Image.Image, + cutoff: float | tuple[float, float] = 0, + ignore: int | Sequence[int] | None = None, + mask: Image.Image | None = None, + preserve_tone: bool = False, +) -> Image.Image: """ Maximize (normalize) image contrast. This function calculates a histogram of the input image (or mask region), removes ``cutoff`` percent of the @@ -94,10 +105,9 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): h = histogram[layer : layer + 256] if ignore is not None: # get rid of outliers - try: + if isinstance(ignore, int): h[ignore] = 0 - except TypeError: - # assume sequence + else: for ix in ignore: h[ix] = 0 if cutoff: @@ -109,7 +119,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): for ix in range(256): n = n + h[ix] # remove cutoff% pixels from the low end - cut = n * cutoff[0] // 100 + cut = int(n * cutoff[0] // 100) for lo in range(256): if cut > h[lo]: cut = cut - h[lo] @@ -120,7 +130,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): if cut <= 0: break # remove cutoff% samples from the high end - cut = n * cutoff[1] // 100 + cut = int(n * cutoff[1] // 100) for hi in range(255, -1, -1): if cut > h[hi]: cut = cut - h[hi] @@ -153,7 +163,15 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): return _lut(image, lut) -def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127): +def colorize( + image: Image.Image, + black: str | tuple[int, ...], + white: str | tuple[int, ...], + mid: str | int | tuple[int, ...] | None = None, + blackpoint: int = 0, + whitepoint: int = 255, + midpoint: int = 127, +) -> Image.Image: """ Colorize grayscale image. This function calculates a color wedge which maps all black pixels in @@ -185,10 +203,9 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi assert 0 <= blackpoint <= midpoint <= whitepoint <= 255 # Define colors from arguments - black = _color(black, "RGB") - white = _color(white, "RGB") - if mid is not None: - mid = _color(mid, "RGB") + rgb_black = cast(Sequence[int], _color(black, "RGB")) + rgb_white = cast(Sequence[int], _color(white, "RGB")) + rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None # Empty lists for the mapping red = [] @@ -197,56 +214,71 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the low-end values for i in range(0, blackpoint): - red.append(black[0]) - green.append(black[1]) - blue.append(black[2]) + red.append(rgb_black[0]) + green.append(rgb_black[1]) + blue.append(rgb_black[2]) # Create the mapping (2-color) - if mid is None: - + if rgb_mid is None: range_map = range(0, whitepoint - blackpoint) for i in range_map: - red.append(black[0] + i * (white[0] - black[0]) // len(range_map)) - green.append(black[1] + i * (white[1] - black[1]) // len(range_map)) - blue.append(black[2] + i * (white[2] - black[2]) // len(range_map)) + red.append( + rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map) + ) + green.append( + rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map) + ) + blue.append( + rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map) + ) # Create the mapping (3-color) else: - range_map1 = range(0, midpoint - blackpoint) range_map2 = range(0, whitepoint - midpoint) for i in range_map1: - red.append(black[0] + i * (mid[0] - black[0]) // len(range_map1)) - green.append(black[1] + i * (mid[1] - black[1]) // len(range_map1)) - blue.append(black[2] + i * (mid[2] - black[2]) // len(range_map1)) + red.append( + rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1) + ) + green.append( + rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1) + ) + blue.append( + rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1) + ) for i in range_map2: - red.append(mid[0] + i * (white[0] - mid[0]) // len(range_map2)) - green.append(mid[1] + i * (white[1] - mid[1]) // len(range_map2)) - blue.append(mid[2] + i * (white[2] - mid[2]) // len(range_map2)) + red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2)) + green.append( + rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2) + ) + blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) # Create the high-end values for i in range(0, 256 - whitepoint): - red.append(white[0]) - green.append(white[1]) - blue.append(white[2]) + red.append(rgb_white[0]) + green.append(rgb_white[1]) + blue.append(rgb_white[2]) # Return converted image image = image.convert("RGB") return _lut(image, red + green + blue) -def contain(image, size, method=Image.BICUBIC): +def contain( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a resized version of the image, set to the maximum width and height within the requested size, while maintaining the original aspect ratio. - :param image: The image to resize and crop. + :param image: The image to resize. :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :return: An image. """ @@ -255,17 +287,54 @@ def contain(image, size, method=Image.BICUBIC): if im_ratio != dest_ratio: if im_ratio > dest_ratio: - new_height = int(image.height / image.width * size[0]) + new_height = round(image.height / image.width * size[0]) + if new_height != size[1]: + size = (size[0], new_height) + else: + new_width = round(image.width / image.height * size[1]) + if new_width != size[0]: + size = (new_width, size[1]) + return image.resize(size, resample=method) + + +def cover( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: + """ + Returns a resized version of the image, so that the requested size is + covered, while maintaining the original aspect ratio. + + :param image: The image to resize. + :param size: The requested output size in pixels, given as a + (width, height) tuple. + :param method: Resampling method to use. Default is + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. + :return: An image. + """ + + im_ratio = image.width / image.height + dest_ratio = size[0] / size[1] + + if im_ratio != dest_ratio: + if im_ratio < dest_ratio: + new_height = round(image.height / image.width * size[0]) if new_height != size[1]: size = (size[0], new_height) else: - new_width = int(image.width / image.height * size[1]) + new_width = round(image.width / image.height * size[1]) if new_width != size[0]: size = (new_width, size[1]) return image.resize(size, resample=method) -def pad(image, size, method=Image.BICUBIC, color=None, centering=(0.5, 0.5)): +def pad( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + color: str | int | tuple[int, ...] | None = None, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and padded version of the image, expanded to fill the requested aspect ratio and size. @@ -274,7 +343,8 @@ def pad(image, size, method=Image.BICUBIC, color=None, centering=(0.5, 0.5)): :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param color: The background color of the padded image. :param centering: Control the position of the original image within the padded version. @@ -291,16 +361,20 @@ def pad(image, size, method=Image.BICUBIC, color=None, centering=(0.5, 0.5)): out = resized else: out = Image.new(image.mode, size, color) + if resized.palette: + palette = resized.getpalette() + if palette is not None: + out.putpalette(palette) if resized.width != size[0]: - x = int((size[0] - resized.width) * max(0, min(centering[0], 1))) + x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) out.paste(resized, (x, 0)) else: - y = int((size[1] - resized.height) * max(0, min(centering[1], 1))) + y = round((size[1] - resized.height) * max(0, min(centering[1], 1))) out.paste(resized, (0, y)) return out -def crop(image, border=0): +def crop(image: Image.Image, border: int = 0) -> Image.Image: """ Remove border from image. The same amount of pixels are removed from all four sides. This function works on all image modes. @@ -315,7 +389,9 @@ def crop(image, border=0): return image.crop((left, top, image.size[0] - right, image.size[1] - bottom)) -def scale(image, factor, resample=Image.BICUBIC): +def scale( + image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a rescaled image by a specific factor given in parameter. A factor greater than 1 expands the image, between 0 and 1 contracts the @@ -324,19 +400,41 @@ def scale(image, factor, resample=Image.BICUBIC): :param image: The image to rescale. :param factor: The expansion factor, as a float. :param resample: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :returns: An :py:class:`~PIL.Image.Image` object. """ if factor == 1: return image.copy() elif factor <= 0: - raise ValueError("the factor must be greater than 0") + msg = "the factor must be greater than 0" + raise ValueError(msg) else: size = (round(factor * image.width), round(factor * image.height)) return image.resize(size, resample) -def deform(image, deformer, resample=Image.BILINEAR): +class SupportsGetMesh(Protocol): + """ + An object that supports the ``getmesh`` method, taking an image as an + argument, and returning a list of tuples. Each tuple contains two tuples, + the source box as a tuple of 4 integers, and a tuple of 8 integers for the + final quadrilateral, in order of top left, bottom left, bottom right, top + right. + """ + + def getmesh( + self, image: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: ... + + +def deform( + image: Image.Image, + deformer: SupportsGetMesh, + resample: int = Image.Resampling.BILINEAR, +) -> Image.Image: """ Deform the image. @@ -347,10 +445,12 @@ def deform(image, deformer, resample=Image.BILINEAR): in the PIL.Image.transform function. :return: An image. """ - return image.transform(image.size, Image.MESH, deformer.getmesh(image), resample) + return image.transform( + image.size, Image.Transform.MESH, deformer.getmesh(image), resample + ) -def equalize(image, mask=None): +def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image: """ Equalize the image histogram. This function applies a non-linear mapping to the input image, in order to create a uniform @@ -381,7 +481,11 @@ def equalize(image, mask=None): return _lut(image, lut) -def expand(image, border=0, fill=0): +def expand( + image: Image.Image, + border: int | tuple[int, ...] = 0, + fill: str | int | tuple[int, ...] = 0, +) -> Image.Image: """ Add border to the image @@ -394,10 +498,9 @@ def expand(image, border=0, fill=0): width = left + image.size[0] + right height = top + image.size[1] + bottom color = _color(fill, image.mode) - if image.mode == "P" and image.palette: - image.load() - palette = image.palette.copy() - if isinstance(color, tuple): + if image.palette: + palette = ImagePalette.ImagePalette(palette=image.getpalette()) + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): color = palette.getcolor(color) else: palette = None @@ -408,7 +511,13 @@ def expand(image, border=0, fill=0): return out -def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): +def fit( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + bleed: float = 0.0, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and cropped version of the image, cropped to the requested aspect ratio and size. @@ -419,7 +528,8 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): :param size: The requested output size in pixels, given as a (width, height) tuple. :param method: Resampling method to use. Default is - :py:attr:`PIL.Image.BICUBIC`. See :ref:`concept-filters`. + :py:attr:`~PIL.Image.Resampling.BICUBIC`. + See :ref:`concept-filters`. :param bleed: Remove a border around the outside of the image from all four edges. The value is a decimal percentage (use 0.01 for one percent). The default value is 0 (no border). @@ -439,15 +549,14 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): # by Kevin Cazabon, Feb 17/2000 # kevin@cazabon.com - # http://www.cazabon.com + # https://www.cazabon.com - # ensure centering is mutable - centering = list(centering) + centering_x, centering_y = centering - if not 0.0 <= centering[0] <= 1.0: - centering[0] = 0.5 - if not 0.0 <= centering[1] <= 1.0: - centering[1] = 0.5 + if not 0.0 <= centering_x <= 1.0: + centering_x = 0.5 + if not 0.0 <= centering_y <= 1.0: + centering_y = 0.5 if not 0.0 <= bleed < 0.5: bleed = 0.0 @@ -484,8 +593,8 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): crop_height = live_size[0] / output_ratio # make the crop - crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering[0] - crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering[1] + crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x + crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height) @@ -493,17 +602,17 @@ def fit(image, size, method=Image.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): return image.resize(size, method, box=crop) -def flip(image): +def flip(image: Image.Image) -> Image.Image: """ Flip the image vertically (top to bottom). :param image: The image to flip. :return: An image. """ - return image.transpose(Image.FLIP_TOP_BOTTOM) + return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) -def grayscale(image): +def grayscale(image: Image.Image) -> Image.Image: """ Convert the image to grayscale. @@ -513,30 +622,28 @@ def grayscale(image): return image.convert("L") -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. :param image: The image to invert. :return: An image. """ - lut = [] - for i in range(256): - lut.append(255 - i) - return _lut(image, lut) + lut = list(range(255, -1, -1)) + return image.point(lut) if image.mode == "1" else _lut(image, lut) -def mirror(image): +def mirror(image: Image.Image) -> Image.Image: """ Flip image horizontally (left to right). :param image: The image to mirror. :return: An image. """ - return image.transpose(Image.FLIP_LEFT_RIGHT) + return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) -def posterize(image, bits): +def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. @@ -544,19 +651,17 @@ def posterize(image, bits): :param bits: The number of bits to keep for each channel (1-8). :return: An image. """ - lut = [] mask = ~(2 ** (8 - bits) - 1) - for i in range(256): - lut.append(i & mask) + lut = [i & mask for i in range(256)] return _lut(image, lut) -def solarize(image, threshold=128): +def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: """ Invert all pixel values above a threshold. :param image: The image to solarize. - :param threshold: All pixels above this greyscale level are inverted. + :param threshold: All pixels above this grayscale level are inverted. :return: An image. """ lut = [] @@ -568,41 +673,59 @@ def solarize(image, threshold=128): return _lut(image, lut) -def exif_transpose(image): +def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ - If an image has an EXIF Orientation tag, return a new image that is - transposed accordingly. Otherwise, return a copy of the image. + If an image has an EXIF Orientation tag, other than 1, transpose the image + accordingly, and remove the orientation data. :param image: The image to transpose. - :return: An image. - """ - exif = image.getexif() - orientation = exif.get(0x0112) + :param in_place: Boolean. Keyword-only argument. + If ``True``, the original image is modified in-place, and ``None`` is returned. + If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned + with the transposition applied. If there is no transposition, a copy of the + image will be returned. + """ + image.load() + image_exif = image.getexif() + orientation = image_exif.get(ExifTags.Base.Orientation, 1) method = { - 2: Image.FLIP_LEFT_RIGHT, - 3: Image.ROTATE_180, - 4: Image.FLIP_TOP_BOTTOM, - 5: Image.TRANSPOSE, - 6: Image.ROTATE_270, - 7: Image.TRANSVERSE, - 8: Image.ROTATE_90, + 2: Image.Transpose.FLIP_LEFT_RIGHT, + 3: Image.Transpose.ROTATE_180, + 4: Image.Transpose.FLIP_TOP_BOTTOM, + 5: Image.Transpose.TRANSPOSE, + 6: Image.Transpose.ROTATE_270, + 7: Image.Transpose.TRANSVERSE, + 8: Image.Transpose.ROTATE_90, }.get(orientation) if method is not None: - transposed_image = image.transpose(method) - transposed_exif = transposed_image.getexif() - if 0x0112 in transposed_exif: - del transposed_exif[0x0112] - if "exif" in transposed_image.info: - transposed_image.info["exif"] = transposed_exif.tobytes() - elif "Raw profile type exif" in transposed_image.info: - transposed_image.info[ - "Raw profile type exif" - ] = transposed_exif.tobytes().hex() - elif "XML:com.adobe.xmp" in transposed_image.info: - transposed_image.info["XML:com.adobe.xmp"] = re.sub( - r'tiff:Orientation="([0-9])"', - "", - transposed_image.info["XML:com.adobe.xmp"], - ) - return transposed_image - return image.copy() + if in_place: + image.im = image.im.transpose(method) + image._size = image.im.size + else: + transposed_image = image.transpose(method) + exif_image = image if in_place else transposed_image + + exif = exif_image.getexif() + if ExifTags.Base.Orientation in exif: + del exif[ExifTags.Base.Orientation] + if "exif" in exif_image.info: + exif_image.info["exif"] = exif.tobytes() + elif "Raw profile type exif" in exif_image.info: + exif_image.info["Raw profile type exif"] = exif.tobytes().hex() + for key in ("XML:com.adobe.xmp", "xmp"): + if key in exif_image.info: + for pattern in ( + r'tiff:Orientation="([0-9])"', + r"([0-9])", + ): + value = exif_image.info[key] + exif_image.info[key] = ( + re.sub(pattern, "", value) + if isinstance(value, str) + else re.sub(pattern.encode(), b"", value) + ) + if not in_place: + return transposed_image + elif not in_place: + return image.copy() + return None diff --git a/dependencies/WIN/PIL/ImagePalette.py b/dependencies/WIN/PIL/ImagePalette.py index 36826bd..183f855 100644 --- a/dependencies/WIN/PIL/ImagePalette.py +++ b/dependencies/WIN/PIL/ImagePalette.py @@ -15,12 +15,17 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import array -import warnings +from collections.abc import Sequence +from typing import IO, TYPE_CHECKING from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ @@ -32,41 +37,44 @@ class ImagePalette: an array or a list of ints between 0-255. The list must consist of all channels for one color followed by the next color (e.g. RGBRGBRGB). Defaults to an empty palette. - :param size: An optional palette size. If given, an error is raised - if ``palette`` is not of equal length. """ - def __init__(self, mode="RGB", palette=None, size=0): + def __init__( + self, + mode: str = "RGB", + palette: Sequence[int] | bytes | bytearray | None = None, + ) -> None: self.mode = mode - self.rawmode = None # if set, palette contains raw data + self.rawmode: str | None = None # if set, palette contains raw data self.palette = palette or bytearray() - self.dirty = None - if size != 0: - warnings.warn( - "The size parameter is deprecated and will be removed in Pillow 10 " - "(2023-01-02).", - DeprecationWarning, - ) - if size != len(self.palette): - raise ValueError("wrong palette size") + self.dirty: int | None = None @property - def palette(self): + def palette(self) -> Sequence[int] | bytes | bytearray: return self._palette @palette.setter - def palette(self, palette): + def palette(self, palette: Sequence[int] | bytes | bytearray) -> None: + self._colors: dict[tuple[int, ...], int] | None = None self._palette = palette - mode_len = len(self.mode) - self.colors = {} - for i in range(0, len(self.palette), mode_len): - color = tuple(self.palette[i : i + mode_len]) - if color in self.colors: - continue - self.colors[color] = i // mode_len - - def copy(self): + @property + def colors(self) -> dict[tuple[int, ...], int]: + if self._colors is None: + mode_len = len(self.mode) + self._colors = {} + for i in range(0, len(self.palette), mode_len): + color = tuple(self.palette[i : i + mode_len]) + if color in self._colors: + continue + self._colors[color] = i // mode_len + return self._colors + + @colors.setter + def colors(self, colors: dict[tuple[int, ...], int]) -> None: + self._colors = colors + + def copy(self) -> ImagePalette: new = ImagePalette() new.mode = self.mode @@ -77,7 +85,7 @@ def copy(self): return new - def getdata(self): + def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]: """ Get palette contents in format suitable for the low-level ``im.putpalette`` primitive. @@ -88,13 +96,14 @@ def getdata(self): return self.rawmode, self.palette return self.mode, self.tobytes() - def tobytes(self): + def tobytes(self) -> bytes: """Convert palette to bytes. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(self.palette, bytes): return self.palette arr = array.array("B", self.palette) @@ -103,16 +112,50 @@ def tobytes(self): # Declare tostring as an alias for tobytes tostring = tobytes - def getcolor(self, color, image=None): + def _new_color_index( + self, image: Image.Image | None = None, e: Exception | None = None + ) -> int: + if not isinstance(self.palette, bytearray): + self._palette = bytearray(self.palette) + index = len(self.palette) // 3 + special_colors: tuple[int | tuple[int, ...] | None, ...] = () + if image: + special_colors = ( + image.info.get("background"), + image.info.get("transparency"), + ) + while index in special_colors: + index += 1 + if index >= 256: + if image: + # Search for an unused index + for i, count in reversed(list(enumerate(image.histogram()))): + if count == 0 and i not in special_colors: + index = i + break + if index >= 256: + msg = "cannot allocate more than 256 colors" + raise ValueError(msg) from e + return index + + def getcolor( + self, + color: tuple[int, ...], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(color, tuple): if self.mode == "RGB": - if len(color) == 4 and color[3] == 255: + if len(color) == 4: + if color[3] != 255: + msg = "cannot add non-opaque RGBA color to RGB palette" + raise ValueError(msg) color = color[:3] elif self.mode == "RGBA": if len(color) == 3: @@ -121,47 +164,31 @@ def getcolor(self, color, image=None): return self.colors[color] except KeyError as e: # allocate new color slot - if not isinstance(self.palette, bytearray): - self._palette = bytearray(self.palette) - index = len(self.palette) // 3 - special_colors = () - if image: - special_colors = ( - image.info.get("background"), - image.info.get("transparency"), - ) - while index in special_colors: - index += 1 - if index >= 256: - if image: - # Search for an unused index - for i, count in reversed(list(enumerate(image.histogram()))): - if count == 0 and i not in special_colors: - index = i - break - if index >= 256: - raise ValueError("cannot allocate more than 256 colors") from e + index = self._new_color_index(image, e) + assert isinstance(self._palette, bytearray) self.colors[color] = index if index * 3 < len(self.palette): self._palette = ( - self.palette[: index * 3] + self._palette[: index * 3] + bytes(color) - + self.palette[index * 3 + 3 :] + + self._palette[index * 3 + 3 :] ) else: self._palette += bytes(color) self.dirty = 1 return index else: - raise ValueError(f"unknown color specifier: {repr(color)}") + msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable] + raise ValueError(msg) - def save(self, fp): + def save(self, fp: str | IO[str]) -> None: """Save palette to text file. .. warning:: This method is experimental. """ if self.rawmode: - raise ValueError("palette contains raw palette data") + msg = "palette contains raw palette data" + raise ValueError(msg) if isinstance(fp, str): fp = open(fp, "w") fp.write("# Palette\n") @@ -181,7 +208,7 @@ def save(self, fp): # Internal -def raw(rawmode, data): +def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data @@ -193,69 +220,66 @@ def raw(rawmode, data): # Factories -def make_linear_lut(black, white): - lut = [] +def make_linear_lut(black: int, white: float) -> list[int]: if black == 0: - for i in range(256): - lut.append(white * i // 255) - else: - raise NotImplementedError # FIXME - return lut + return [int(white * i // 255) for i in range(256)] + + msg = "unavailable when black is non-zero" + raise NotImplementedError(msg) # FIXME -def make_gamma_lut(exp): - lut = [] - for i in range(256): - lut.append(int(((i / 255.0) ** exp) * 255.0 + 0.5)) - return lut +def make_gamma_lut(exp: float) -> list[int]: + return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)] -def negative(mode="RGB"): +def negative(mode: str = "RGB") -> ImagePalette: palette = list(range(256 * len(mode))) palette.reverse() return ImagePalette(mode, [i // len(mode) for i in palette]) -def random(mode="RGB"): +def random(mode: str = "RGB") -> ImagePalette: from random import randint - palette = [] - for i in range(256 * len(mode)): - palette.append(randint(0, 255)) + palette = [randint(0, 255) for _ in range(256 * len(mode))] return ImagePalette(mode, palette) -def sepia(white="#fff0c0"): +def sepia(white: str = "#fff0c0") -> ImagePalette: bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)] return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)]) -def wedge(mode="RGB"): +def wedge(mode: str = "RGB") -> ImagePalette: palette = list(range(256 * len(mode))) return ImagePalette(mode, [i // len(mode) for i in palette]) -def load(filename): - +def load(filename: str) -> tuple[bytes, str]: # FIXME: supports GIMP gradients only with open(filename, "rb") as fp: - - for paletteHandler in [ + paletteHandlers: list[ + type[ + GimpPaletteFile.GimpPaletteFile + | GimpGradientFile.GimpGradientFile + | PaletteFile.PaletteFile + ] + ] = [ GimpPaletteFile.GimpPaletteFile, GimpGradientFile.GimpGradientFile, PaletteFile.PaletteFile, - ]: + ] + for paletteHandler in paletteHandlers: try: fp.seek(0) lut = paletteHandler(fp).getpalette() if lut: break except (SyntaxError, ValueError): - # import traceback - # traceback.print_exc() pass else: - raise OSError("cannot load palette") + msg = "cannot load palette" + raise OSError(msg) return lut # data, rawmode diff --git a/dependencies/WIN/PIL/ImagePath.py b/dependencies/WIN/PIL/ImagePath.py index 3d3538c..77e8a60 100644 --- a/dependencies/WIN/PIL/ImagePath.py +++ b/dependencies/WIN/PIL/ImagePath.py @@ -13,6 +13,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image diff --git a/dependencies/WIN/PIL/ImageQt.py b/dependencies/WIN/PIL/ImageQt.py index 32630f2..2cc40f8 100644 --- a/dependencies/WIN/PIL/ImageQt.py +++ b/dependencies/WIN/PIL/ImageQt.py @@ -15,59 +15,75 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys from io import BytesIO +from typing import TYPE_CHECKING, Any, Callable, Union from . import Image -from ._util import isPath +from ._util import is_path +if TYPE_CHECKING: + import PyQt6 + import PySide6 + + from . import ImageFile + + QBuffer: type + QByteArray = Union[PyQt6.QtCore.QByteArray, PySide6.QtCore.QByteArray] + QIODevice = Union[PyQt6.QtCore.QIODevice, PySide6.QtCore.QIODevice] + QImage = Union[PyQt6.QtGui.QImage, PySide6.QtGui.QImage] + QPixmap = Union[PyQt6.QtGui.QPixmap, PySide6.QtGui.QPixmap] + +qt_version: str | None qt_versions = [ ["6", "PyQt6"], ["side6", "PySide6"], - ["5", "PyQt5"], - ["side2", "PySide2"], ] # If a version has already been imported, attempt it first -qt_versions.sort(key=lambda qt_version: qt_version[1] in sys.modules, reverse=True) -for qt_version, qt_module in qt_versions: +qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) +for version, qt_module in qt_versions: try: + qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtGui import QImage, QPixmap, qRgba elif qt_module == "PySide6": from PySide6.QtCore import QBuffer, QIODevice from PySide6.QtGui import QImage, QPixmap, qRgba - elif qt_module == "PyQt5": - from PyQt5.QtCore import QBuffer, QIODevice - from PyQt5.QtGui import QImage, QPixmap, qRgba - elif qt_module == "PySide2": - from PySide2.QtCore import QBuffer, QIODevice - from PySide2.QtGui import QImage, QPixmap, qRgba except (ImportError, RuntimeError): continue qt_is_installed = True + qt_version = version break else: qt_is_installed = False qt_version = None -def rgb(r, g, b, a=255): +def rgb(r: int, g: int, b: int, a: int = 255) -> int: """(Internal) Turns an RGB color into a Qt compatible color integer.""" # use qRgb to pack the colors, and then turn the resulting long # into a negative integer with the same bitpattern. return qRgba(r, g, b, a) & 0xFFFFFFFF -def fromqimage(im): +def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile: """ :param im: QImage or PIL ImageQt object """ buffer = QBuffer() - qt_openmode = QIODevice.OpenMode if qt_version == "6" else QIODevice - buffer.open(qt_openmode.ReadWrite) + qt_openmode: object + if qt_version == "6": + try: + qt_openmode = getattr(QIODevice, "OpenModeFlag") + except AttributeError: + qt_openmode = getattr(QIODevice, "OpenMode") + else: + qt_openmode = QIODevice + buffer.open(getattr(qt_openmode, "ReadWrite")) # preserve alpha channel with png # otherwise ppm is more friendly with Image.open if im.hasAlphaChannel(): @@ -83,26 +99,16 @@ def fromqimage(im): return Image.open(b) -def fromqpixmap(im): +def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile: return fromqimage(im) - # buffer = QBuffer() - # buffer.open(QIODevice.ReadWrite) - # # im.save(buffer) - # # What if png doesn't support some image features like animation? - # im.save(buffer, 'ppm') - # bytes_io = BytesIO() - # bytes_io.write(buffer.data()) - # buffer.close() - # bytes_io.seek(0) - # return Image.open(bytes_io) - - -def align8to32(bytes, width, mode): + + +def align8to32(bytes: bytes, width: int, mode: str) -> bytes: """ converts each scanline of data from 8 bit to 32 bit aligned """ - bits_per_pixel = {"1": 1, "L": 8, "P": 8}[mode] + bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode] # calculate bytes per line and the extra padding if needed bits_per_line = bits_per_pixel * width @@ -115,17 +121,15 @@ def align8to32(bytes, width, mode): if not extra_padding: return bytes - new_data = [] - for i in range(len(bytes) // bytes_per_line): - new_data.append( - bytes[i * bytes_per_line : (i + 1) * bytes_per_line] - + b"\x00" * extra_padding - ) + new_data = [ + bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding + for i in range(len(bytes) // bytes_per_line) + ] return b"".join(new_data) -def _toqclass_helper(im): +def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]: data = None colortable = None exclusive_fp = False @@ -134,37 +138,40 @@ def _toqclass_helper(im): if hasattr(im, "toUtf8"): # FIXME - is this really the best way to do this? im = str(im.toUtf8(), "utf-8") - if isPath(im): + if is_path(im): im = Image.open(im) exclusive_fp = True + assert isinstance(im, Image.Image) - qt_format = QImage.Format if qt_version == "6" else QImage + qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage if im.mode == "1": - format = qt_format.Format_Mono + format = getattr(qt_format, "Format_Mono") elif im.mode == "L": - format = qt_format.Format_Indexed8 - colortable = [] - for i in range(256): - colortable.append(rgb(i, i, i)) + format = getattr(qt_format, "Format_Indexed8") + colortable = [rgb(i, i, i) for i in range(256)] elif im.mode == "P": - format = qt_format.Format_Indexed8 - colortable = [] + format = getattr(qt_format, "Format_Indexed8") palette = im.getpalette() - for i in range(0, len(palette), 3): - colortable.append(rgb(*palette[i : i + 3])) + assert palette is not None + colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)] elif im.mode == "RGB": # Populate the 4th channel with 255 im = im.convert("RGBA") data = im.tobytes("raw", "BGRA") - format = qt_format.Format_RGB32 + format = getattr(qt_format, "Format_RGB32") elif im.mode == "RGBA": data = im.tobytes("raw", "BGRA") - format = qt_format.Format_ARGB32 + format = getattr(qt_format, "Format_ARGB32") + elif im.mode == "I;16": + im = im.point(lambda i: i * 256) + + format = getattr(qt_format, "Format_Grayscale16") else: if exclusive_fp: im.close() - raise ValueError(f"unsupported image mode {repr(im.mode)}") + msg = f"unsupported image mode {repr(im.mode)}" + raise ValueError(msg) size = im.size __data = data or align8to32(im.tobytes(), size[0], im.mode) @@ -175,8 +182,8 @@ def _toqclass_helper(im): if qt_is_installed: - class ImageQt(QImage): - def __init__(self, im): + class ImageQt(QImage): # type: ignore[misc] + def __init__(self, im: Image.Image | str | QByteArray) -> None: """ An PIL image wrapper for Qt. This is a subclass of PyQt's QImage class. @@ -200,14 +207,13 @@ def __init__(self, im): self.setColorTable(im_data["colortable"]) -def toqimage(im): +def toqimage(im: Image.Image | str | QByteArray) -> ImageQt: return ImageQt(im) -def toqpixmap(im): - # # This doesn't work. For now using a dumb approach. - # im_data = _toqclass_helper(im) - # result = QPixmap(im_data["size"][0], im_data["size"][1]) - # result.loadFromData(im_data["data"]) +def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap: qimage = toqimage(im) - return QPixmap.fromImage(qimage) + pixmap = getattr(QPixmap, "fromImage")(qimage) + if qt_version == "6": + pixmap.detach() + return pixmap diff --git a/dependencies/WIN/PIL/ImageSequence.py b/dependencies/WIN/PIL/ImageSequence.py index 9df910a..a6fc340 100644 --- a/dependencies/WIN/PIL/ImageSequence.py +++ b/dependencies/WIN/PIL/ImageSequence.py @@ -14,6 +14,11 @@ # ## +from __future__ import annotations + +from typing import Callable + +from . import Image class Iterator: @@ -28,32 +33,38 @@ class Iterator: :param im: An image object. """ - def __init__(self, im): + def __init__(self, im: Image.Image) -> None: if not hasattr(im, "seek"): - raise AttributeError("im must have seek method") + msg = "im must have seek method" + raise AttributeError(msg) self.im = im self.position = getattr(self.im, "_min_frame", 0) - def __getitem__(self, ix): + def __getitem__(self, ix: int) -> Image.Image: try: self.im.seek(ix) return self.im except EOFError as e: - raise IndexError from e # end of sequence + msg = "end of sequence" + raise IndexError(msg) from e - def __iter__(self): + def __iter__(self) -> Iterator: return self - def __next__(self): + def __next__(self) -> Image.Image: try: self.im.seek(self.position) self.position += 1 return self.im except EOFError as e: - raise StopIteration from e + msg = "end of sequence" + raise StopIteration(msg) from e -def all_frames(im, func=None): +def all_frames( + im: Image.Image | list[Image.Image], + func: Callable[[Image.Image], Image.Image] | None = None, +) -> list[Image.Image]: """ Applies a given function to all frames in an image or a list of images. The frames are returned as a list of separate images. diff --git a/dependencies/WIN/PIL/ImageShow.py b/dependencies/WIN/PIL/ImageShow.py index 60c9754..d62893d 100644 --- a/dependencies/WIN/PIL/ImageShow.py +++ b/dependencies/WIN/PIL/ImageShow.py @@ -11,39 +11,44 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +import abc import os import shutil import subprocess import sys -import tempfile from shlex import quote +from typing import Any -from PIL import Image +from . import Image _viewers = [] -def register(viewer, order=1): +def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None: """ - The :py:func:`register` function is used to register additional viewers. + The :py:func:`register` function is used to register additional viewers:: + + from PIL import ImageShow + ImageShow.register(MyViewer()) # MyViewer will be used as a last resort + ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised + ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised :param viewer: The viewer to be registered. :param order: Zero or a negative integer to prepend this viewer to the list, a positive integer to append it. """ - try: - if issubclass(viewer, Viewer): - viewer = viewer() - except TypeError: - pass # raised if viewer wasn't a class + if isinstance(viewer, type) and issubclass(viewer, Viewer): + viewer = viewer() if order > 0: _viewers.append(viewer) else: _viewers.insert(0, viewer) -def show(image, title=None, **options): +def show(image: Image.Image, title: str | None = None, **options: Any) -> bool: r""" Display a given image. @@ -54,8 +59,8 @@ def show(image, title=None, **options): """ for viewer in _viewers: if viewer.show(image, title=title, **options): - return 1 - return 0 + return True + return False class Viewer: @@ -63,7 +68,7 @@ class Viewer: # main api - def show(self, image, **options): + def show(self, image: Image.Image, **options: Any) -> int: """ The main function for displaying an image. Converts the given image to the target format and displays it. @@ -81,33 +86,38 @@ def show(self, image, **options): # hook methods - format = None + format: str | None = None """The format to convert the image into.""" - options = {} + options: dict[str, Any] = {} """Additional options used to convert the image.""" - def get_format(self, image): + def get_format(self, image: Image.Image) -> str | None: """Return format name, or ``None`` to save as PGM/PPM.""" return self.format - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: """ Returns the command used to display the file. Not implemented in the base class. """ - raise NotImplementedError + msg = "unavailable in base viewer" + raise NotImplementedError(msg) - def save_image(self, image): + def save_image(self, image: Image.Image) -> str: """Save to temporary file and return filename.""" return image._dump(format=self.get_format(image), **self.options) - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> int: """Display the given image.""" return self.show_file(self.save_image(image), **options) - def show_file(self, file, **options): - """Display the given file.""" - os.system(self.get_command(file, **options)) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + os.system(self.get_command(path, **options)) # nosec return 1 @@ -118,15 +128,28 @@ class WindowsViewer(Viewer): """The default viewer on Windows is the default system application for PNG files.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' - "&& ping -n 2 127.0.0.1 >NUL " + "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' ) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen( + self.get_command(path, **options), + shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), + ) # nosec + return 1 + if sys.platform == "win32": register(WindowsViewer) @@ -136,27 +159,32 @@ class MacViewer(Viewer): """The default viewer on macOS using ``Preview.app``.""" format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: # on darwin open returns immediately resulting in the temp # file removal while app is opening command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command - def show_file(self, file, **options): - """Display given file""" - fd, path = tempfile.mkstemp() - with os.fdopen(fd, "w") as f: - f.write(file) - with open(path) as f: + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.call(["open", "-a", "Preview.app", path]) + executable = sys.executable or shutil.which("python3") + if executable: subprocess.Popen( - ["im=$(cat); open -a Preview.app $im; sleep 20; rm -f $im"], - shell=True, - stdin=f, + [ + executable, + "-c", + "import os, sys, time; time.sleep(20); os.remove(sys.argv[1])", + path, + ] ) - os.remove(path) return 1 @@ -166,51 +194,101 @@ def show_file(self, file, **options): class UnixViewer(Viewer): format = "PNG" - options = {"compress_level": 1} + options = {"compress_level": 1, "save_all": True} + + @abc.abstractmethod + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + pass - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: command = self.get_command_ex(file, **options)[0] - return f"({command} {quote(file)}; rm -f {quote(file)})&" - - def show_file(self, file, **options): - """Display given file""" - fd, path = tempfile.mkstemp() - with os.fdopen(fd, "w") as f: - f.write(file) - with open(path) as f: - command = self.get_command_ex(file, **options)[0] - subprocess.Popen( - ["im=$(cat);" + command + " $im; rm -f $im"], shell=True, stdin=f - ) - os.remove(path) + return f"{command} {quote(file)}" + + +class XDGViewer(UnixViewer): + """ + The freedesktop.org ``xdg-open`` command. + """ + + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + command = executable = "xdg-open" + return command, executable + + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["xdg-open", path]) return 1 class DisplayViewer(UnixViewer): - """The ImageMagick ``display`` command.""" + """ + The ImageMagick ``display`` command. + This viewer supports the ``title`` parameter. + """ - def get_command_ex(self, file, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: command = executable = "display" + if title: + command += f" -title {quote(title)}" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["display"] + title = options.get("title") + if title: + args += ["-title", title] + args.append(path) + + subprocess.Popen(args) + return 1 + class GmDisplayViewer(UnixViewer): """The GraphicsMagick ``gm display`` command.""" - def get_command_ex(self, file, **options): + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: executable = "gm" command = "gm display" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["gm", "display", path]) + return 1 + class EogViewer(UnixViewer): """The GNOME Image Viewer ``eog`` command.""" - def get_command_ex(self, file, **options): + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: executable = "eog" command = "eog -n" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + subprocess.Popen(["eog", "-n", path]) + return 1 + class XVViewer(UnixViewer): """ @@ -218,7 +296,9 @@ class XVViewer(UnixViewer): This viewer supports the ``title`` parameter. """ - def get_command_ex(self, file, title=None, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: # note: xv is pretty outdated. most modern systems have # imagemagick's display command instead. command = executable = "xv" @@ -226,8 +306,25 @@ def get_command_ex(self, file, title=None, **options): command += f" -name {quote(title)}" return command, executable + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + if not os.path.exists(path): + raise FileNotFoundError + args = ["xv"] + title = options.get("title") + if title: + args += ["-name", title] + args.append(path) + + subprocess.Popen(args) + return 1 + if sys.platform not in ("win32", "darwin"): # unixoids + if shutil.which("xdg-open"): + register(XDGViewer) if shutil.which("display"): register(DisplayViewer) if shutil.which("gm"): @@ -241,7 +338,7 @@ def get_command_ex(self, file, title=None, **options): class IPythonViewer(Viewer): """The viewer for IPython frontends.""" - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> int: ipython_display(image) return 1 @@ -255,7 +352,6 @@ def show_image(self, image, **options): if __name__ == "__main__": - if len(sys.argv) < 2: print("Syntax: python3 ImageShow.py imagefile [title]") sys.exit() diff --git a/dependencies/WIN/PIL/ImageStat.py b/dependencies/WIN/PIL/ImageStat.py index 50bafc9..8bc5045 100644 --- a/dependencies/WIN/PIL/ImageStat.py +++ b/dependencies/WIN/PIL/ImageStat.py @@ -20,91 +20,111 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations -import functools import math -import operator +from functools import cached_property + +from . import Image class Stat: - def __init__(self, image_or_list, mask=None): - try: - if mask: - self.h = image_or_list.histogram(mask) - else: - self.h = image_or_list.histogram() - except AttributeError: - self.h = image_or_list # assume it to be a histogram list - if not isinstance(self.h, list): - raise TypeError("first argument must be image or list") + def __init__( + self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None + ) -> None: + """ + Calculate statistics for the given image. If a mask is included, + only the regions covered by that mask are included in the + statistics. You can also pass in a previously calculated histogram. + + :param image: A PIL image, or a precalculated histogram. + + .. note:: + + For a PIL image, calculations rely on the + :py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are + grouped into 256 bins, even if the image has more than 8 bits per + channel. So ``I`` and ``F`` mode images have a maximum ``mean``, + ``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum + of more than 255. + + :param mask: An optional mask. + """ + if isinstance(image_or_list, Image.Image): + self.h = image_or_list.histogram(mask) + elif isinstance(image_or_list, list): + self.h = image_or_list + else: + msg = "first argument must be image or list" # type: ignore[unreachable] + raise TypeError(msg) self.bands = list(range(len(self.h) // 256)) - def __getattr__(self, id): - """Calculate missing attribute""" - if id[:4] == "_get": - raise AttributeError(id) - # calculate missing attribute - v = getattr(self, "_get" + id)() - setattr(self, id, v) - return v - - def _getextrema(self): - """Get min/max values for each band in the image""" - - def minmax(histogram): - n = 255 - x = 0 + @cached_property + def extrema(self) -> list[tuple[int, int]]: + """ + Min/max values for each band in the image. + + .. note:: + This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and + simply returns the low and high bins used. This is correct for + images with 8 bits per channel, but fails for other modes such as + ``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to + return per-band extrema for the image. This is more correct and + efficient because, for non-8-bit modes, the histogram method uses + :py:meth:`~PIL.Image.Image.getextrema` to determine the bins used. + """ + + def minmax(histogram: list[int]) -> tuple[int, int]: + res_min, res_max = 255, 0 for i in range(256): if histogram[i]: - n = min(n, i) - x = max(x, i) - return n, x # returns (255, 0) if there's no data in the histogram - - v = [] - for i in range(0, len(self.h), 256): - v.append(minmax(self.h[i:])) - return v + res_min = i + break + for i in range(255, -1, -1): + if histogram[i]: + res_max = i + break + return res_min, res_max - def _getcount(self): - """Get total number of pixels in each layer""" + return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)] - v = [] - for i in range(0, len(self.h), 256): - v.append(functools.reduce(operator.add, self.h[i : i + 256])) - return v + @cached_property + def count(self) -> list[int]: + """Total number of pixels for each band in the image.""" + return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)] - def _getsum(self): - """Get sum of all pixels in each layer""" + @cached_property + def sum(self) -> list[float]: + """Sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): - layerSum = 0.0 + layer_sum = 0.0 for j in range(256): - layerSum += j * self.h[i + j] - v.append(layerSum) + layer_sum += j * self.h[i + j] + v.append(layer_sum) return v - def _getsum2(self): - """Get squared sum of all pixels in each layer""" + @cached_property + def sum2(self) -> list[float]: + """Squared sum of all pixels for each band in the image.""" v = [] for i in range(0, len(self.h), 256): sum2 = 0.0 for j in range(256): - sum2 += (j ** 2) * float(self.h[i + j]) + sum2 += (j**2) * float(self.h[i + j]) v.append(sum2) return v - def _getmean(self): - """Get average pixel level for each layer""" - - v = [] - for i in self.bands: - v.append(self.sum[i] / self.count[i]) - return v + @cached_property + def mean(self) -> list[float]: + """Average (arithmetic mean) pixel level for each band in the image.""" + return [self.sum[i] / self.count[i] for i in self.bands] - def _getmedian(self): - """Get median pixel level for each layer""" + @cached_property + def median(self) -> list[int]: + """Median pixel level for each band in the image.""" v = [] for i in self.bands: @@ -118,30 +138,23 @@ def _getmedian(self): v.append(j) return v - def _getrms(self): - """Get RMS for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.sum2[i] / self.count[i])) - return v - - def _getvar(self): - """Get variance for each layer""" - - v = [] - for i in self.bands: - n = self.count[i] - v.append((self.sum2[i] - (self.sum[i] ** 2.0) / n) / n) - return v - - def _getstddev(self): - """Get standard deviation for each layer""" - - v = [] - for i in self.bands: - v.append(math.sqrt(self.var[i])) - return v + @cached_property + def rms(self) -> list[float]: + """RMS (root-mean-square) for each band in the image.""" + return [math.sqrt(self.sum2[i] / self.count[i]) for i in self.bands] + + @cached_property + def var(self) -> list[float]: + """Variance for each band in the image.""" + return [ + (self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i] + for i in self.bands + ] + + @cached_property + def stddev(self) -> list[float]: + """Standard deviation for each band in the image.""" + return [math.sqrt(self.var[i]) for i in self.bands] Global = Stat # compatibility diff --git a/dependencies/WIN/PIL/ImageTk.py b/dependencies/WIN/PIL/ImageTk.py index 62db7a7..bf29fdb 100644 --- a/dependencies/WIN/PIL/ImageTk.py +++ b/dependencies/WIN/PIL/ImageTk.py @@ -24,38 +24,45 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import tkinter from io import BytesIO +from typing import TYPE_CHECKING, Any, cast -from . import Image +from . import Image, ImageFile + +if TYPE_CHECKING: + from ._typing import CapsuleType # -------------------------------------------------------------------- # Check for Tkinter interface hooks -_pilbitmap_ok = None - - -def _pilbitmap_check(): - global _pilbitmap_ok - if _pilbitmap_ok is None: - try: - im = Image.new("1", (1, 1)) - tkinter.BitmapImage(data=f"PIL:{im.im.id}") - _pilbitmap_ok = 1 - except tkinter.TclError: - _pilbitmap_ok = 0 - return _pilbitmap_ok - -def _get_image_from_kw(kw): +def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None: source = None if "file" in kw: source = kw.pop("file") elif "data" in kw: source = BytesIO(kw.pop("data")) - if source: - return Image.open(source) + if not source: + return None + return Image.open(source) + + +def _pyimagingtkcall( + command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType +) -> None: + tk = photo.tk + try: + tk.call(command, photo, repr(ptr)) + except tkinter.TclError: + # activate Tkinter hook + # may raise an error if it cannot attach to Tkinter + from . import _imagingtk + + _imagingtk.tkinit(tk.interpaddr()) + tk.call(command, photo, repr(ptr)) # -------------------------------------------------------------------- @@ -82,27 +89,36 @@ class PhotoImage: image file). """ - def __init__(self, image=None, size=None, **kw): - + def __init__( + self, + image: Image.Image | str | None = None, + size: tuple[int, int] | None = None, + **kw: Any, + ) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) - if hasattr(image, "mode") and hasattr(image, "size"): + if image is None: + msg = "Image is required" + raise ValueError(msg) + elif isinstance(image, str): + mode = image + image = None + + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: # got an image instead of a mode mode = image.mode if mode == "P": # palette mapped data + image.apply_transparency() image.load() - try: - mode = image.palette.mode - except AttributeError: - mode = "RGB" # default + mode = image.palette.mode if image.palette else "RGB" size = image.size kw["width"], kw["height"] = size - else: - mode = image - image = None if mode not in ["1", "L", "RGB", "RGBA"]: mode = Image.getmodebase(mode) @@ -114,15 +130,18 @@ def __init__(self, image=None, size=None, **kw): if image: self.paste(image) - def __del__(self): - name = self.__photo.name + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) except Exception: pass # ignore internal errors - def __str__(self): + def __str__(self) -> str: """ Get the Tkinter photo image identifier. This method is automatically called by Tkinter whenever a PhotoImage object is passed to a Tkinter @@ -132,7 +151,7 @@ def __str__(self): """ return str(self.__photo) - def width(self): + def width(self) -> int: """ Get the width of the image. @@ -140,7 +159,7 @@ def width(self): """ return self.__size[0] - def height(self): + def height(self) -> int: """ Get the height of the image. @@ -148,7 +167,7 @@ def height(self): """ return self.__size[1] - def paste(self, im, box=None): + def paste(self, im: Image.Image) -> None: """ Paste a PIL image into the photo image. Note that this can be very slow if the photo image is displayed. @@ -156,47 +175,16 @@ def paste(self, im, box=None): :param im: A PIL image. The size must match the target region. If the mode does not match, the image is converted to the mode of the bitmap image. - :param box: A 4-tuple defining the left, upper, right, and lower pixel - coordinate. See :ref:`coordinate-system`. If None is given - instead of a tuple, all of the image is assumed. """ - # convert to blittable - im.load() + ptr = im.getim() image = im.im - if image.isblock() and im.mode == self.__mode: - block = image - else: - block = image.new_block(self.__mode, im.size) + if not image.isblock() or im.mode != self.__mode: + block = Image.core.new_block(self.__mode, im.size) image.convert2(block, image) # convert directly between buffers + ptr = block.ptr - tk = self.__photo.tk - - try: - tk.call("PyImagingPhoto", self.__photo, block.id) - except tkinter.TclError: - # activate Tkinter hook - try: - from . import _imagingtk - - try: - if hasattr(tk, "interp"): - # Required for PyPy, which always has CFFI installed - from cffi import FFI - - ffi = FFI() - - # PyPy is using an FFI CDATA element - # (Pdb) self.tk.interp - # - _imagingtk.tkinit(int(ffi.cast("uintptr_t", tk.interp)), 1) - else: - _imagingtk.tkinit(tk.interpaddr(), 1) - except AttributeError: - _imagingtk.tkinit(id(tk), 0) - tk.call("PyImagingPhoto", self.__photo, block.id) - except (ImportError, AttributeError, tkinter.TclError): - raise # configuration problem; cannot attach to Tkinter + _pyimagingtkcall("PyImagingPhoto", self.__photo, ptr) # -------------------------------------------------------------------- @@ -217,34 +205,31 @@ class BitmapImage: :param image: A PIL image. """ - def __init__(self, image=None, **kw): - + def __init__(self, image: Image.Image | None = None, **kw: Any) -> None: # Tk compatibility: file or data if image is None: image = _get_image_from_kw(kw) + if image is None: + msg = "Image is required" + raise ValueError(msg) self.__mode = image.mode self.__size = image.size - if _pilbitmap_check(): - # fast way (requires the pilbitmap booster patch) - image.load() - kw["data"] = f"PIL:{image.im.id}" - self.__im = image # must keep a reference - else: - # slow but safe way - kw["data"] = image.tobitmap() - self.__photo = tkinter.BitmapImage(**kw) + self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw) - def __del__(self): - name = self.__photo.name + def __del__(self) -> None: + try: + name = self.__photo.name + except AttributeError: + return self.__photo.name = None try: self.__photo.tk.call("image", "delete", name) except Exception: pass # ignore internal errors - def width(self): + def width(self) -> int: """ Get the width of the image. @@ -252,7 +237,7 @@ def width(self): """ return self.__size[0] - def height(self): + def height(self) -> int: """ Get the height of the image. @@ -260,7 +245,7 @@ def height(self): """ return self.__size[1] - def __str__(self): + def __str__(self) -> str: """ Get the Tkinter bitmap image identifier. This method is automatically called by Tkinter whenever a BitmapImage object is passed to a Tkinter @@ -271,29 +256,34 @@ def __str__(self): return str(self.__photo) -def getimage(photo): +def getimage(photo: PhotoImage) -> Image.Image: """Copies the contents of a PhotoImage to a PIL image memory.""" im = Image.new("RGBA", (photo.width(), photo.height())) - block = im.im - photo.tk.call("PyImagingPhotoGet", photo, block.id) + _pyimagingtkcall("PyImagingPhotoGet", photo, im.getim()) return im -def _show(image, title): +def _show(image: Image.Image, title: str | None) -> None: """Helper for the Image.show method.""" class UI(tkinter.Label): - def __init__(self, master, im): + def __init__(self, master: tkinter.Toplevel, im: Image.Image) -> None: + self.image: BitmapImage | PhotoImage if im.mode == "1": self.image = BitmapImage(im, foreground="white", master=master) else: self.image = PhotoImage(im, master=master) - super().__init__(master, image=self.image, bg="black", bd=0) + if TYPE_CHECKING: + image = cast(tkinter._Image, self.image) + else: + image = self.image + super().__init__(master, image=image, bg="black", bd=0) - if not tkinter._default_root: - raise OSError("tkinter not initialized") + if not getattr(tkinter, "_default_root"): + msg = "tkinter not initialized" + raise OSError(msg) top = tkinter.Toplevel() if title: top.title(title) diff --git a/dependencies/WIN/PIL/ImageTransform.py b/dependencies/WIN/PIL/ImageTransform.py index 77791ab..a3d8f44 100644 --- a/dependencies/WIN/PIL/ImageTransform.py +++ b/dependencies/WIN/PIL/ImageTransform.py @@ -12,18 +12,32 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any from . import Image class Transform(Image.ImageTransformHandler): - def __init__(self, data): + """Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`.""" + + method: Image.Transform + + def __init__(self, data: Sequence[Any]) -> None: self.data = data - def getdata(self): + def getdata(self) -> tuple[Image.Transform, Sequence[int]]: return self.method, self.data - def transform(self, size, image, **options): + def transform( + self, + size: tuple[int, int], + image: Image.Image, + **options: Any, + ) -> Image.Image: + """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden method, data = self.getdata() return image.transform(size, method, data, **options) @@ -41,13 +55,33 @@ class AffineTransform(Transform): This function can be used to scale, translate, rotate, and shear the original image. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows from an affine transform matrix. """ - method = Image.AFFINE + method = Image.Transform.AFFINE + + +class PerspectiveTransform(Transform): + """ + Define a perspective image transform. + + This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel + (x, y) in the output image, the new value is taken from a position + ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in + the input image, rounded to nearest pixel. + + This function can be used to scale, translate, rotate, and shear the + original image. + + See :py:meth:`.Image.transform` + + :param matrix: An 8-tuple (a, b, c, d, e, f, g, h). + """ + + method = Image.Transform.PERSPECTIVE class ExtentTransform(Transform): @@ -63,13 +97,13 @@ class ExtentTransform(Transform): rectangle in the current image. It is slightly slower than crop, but about as fast as a corresponding resize operation. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the input image's coordinate system. See :ref:`coordinate-system`. """ - method = Image.EXTENT + method = Image.Transform.EXTENT class QuadTransform(Transform): @@ -79,14 +113,14 @@ class QuadTransform(Transform): Maps a quadrilateral (a region defined by four corners) from the image to a rectangle of the given size. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the upper left, lower left, lower right, and upper right corner of the source quadrilateral. """ - method = Image.QUAD + method = Image.Transform.QUAD class MeshTransform(Transform): @@ -94,9 +128,9 @@ class MeshTransform(Transform): Define a mesh image transform. A mesh transform consists of one or more individual quad transforms. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param data: A list of (bbox, quad) tuples. """ - method = Image.MESH + method = Image.Transform.MESH diff --git a/dependencies/WIN/PIL/ImageWin.py b/dependencies/WIN/PIL/ImageWin.py index ca9b14c..98c28f2 100644 --- a/dependencies/WIN/PIL/ImageWin.py +++ b/dependencies/WIN/PIL/ImageWin.py @@ -16,6 +16,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image @@ -27,10 +28,10 @@ class HDC: methods. """ - def __init__(self, dc): + def __init__(self, dc: int) -> None: self.dc = dc - def __int__(self): + def __int__(self) -> int: return self.dc @@ -41,10 +42,10 @@ class HWND: methods, instead of a DC. """ - def __init__(self, wnd): + def __init__(self, wnd: int) -> None: self.wnd = wnd - def __int__(self): + def __int__(self) -> int: return self.wnd @@ -54,9 +55,9 @@ class Dib: "L", "P", or "RGB". If the display requires a palette, this constructor creates a suitable - palette and associates it with the image. For an "L" image, 128 greylevels + palette and associates it with the image. For an "L" image, 128 graylevels are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together - with 20 greylevels. + with 20 graylevels. To make sure that palettes work properly under Windows, you must call the ``palette`` method upon certain events from Windows. @@ -68,22 +69,28 @@ class Dib: defines the size of the image. """ - def __init__(self, image, size=None): - if hasattr(image, "mode") and hasattr(image, "size"): + def __init__( + self, image: Image.Image | str, size: tuple[int, int] | None = None + ) -> None: + if isinstance(image, str): + mode = image + image = "" + if size is None: + msg = "If first argument is mode, size is required" + raise ValueError(msg) + else: mode = image.mode size = image.size - else: - mode = image - image = None if mode not in ["1", "L", "P", "RGB"]: mode = Image.getmodebase(mode) self.image = Image.core.display(mode, size) self.mode = mode self.size = size if image: + assert not isinstance(image, str) self.paste(image) - def expose(self, handle): + def expose(self, handle: int | HDC | HWND) -> None: """ Copy the bitmap contents to a device context. @@ -91,17 +98,22 @@ def expose(self, handle): HDC or HWND instance. In PythonWin, you can use ``CDC.GetHandleAttrib()`` to get a suitable handle. """ + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: - result = self.image.expose(dc) + self.image.expose(dc) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - result = self.image.expose(handle) - return result - - def draw(self, handle, dst, src=None): + self.image.expose(handle_int) + + def draw( + self, + handle: int | HDC | HWND, + dst: tuple[int, int, int, int], + src: tuple[int, int, int, int] | None = None, + ) -> None: """ Same as expose, but allows you to specify where to draw the image, and what part of it to draw. @@ -111,19 +123,19 @@ def draw(self, handle, dst, src=None): the destination have different sizes, the image is resized as necessary. """ - if not src: + if src is None: src = (0, 0) + self.size + handle_int = int(handle) if isinstance(handle, HWND): - dc = self.image.getdc(handle) + dc = self.image.getdc(handle_int) try: - result = self.image.draw(dc, dst, src) + self.image.draw(dc, dst, src) finally: - self.image.releasedc(handle, dc) + self.image.releasedc(handle_int, dc) else: - result = self.image.draw(handle, dst, src) - return result + self.image.draw(handle_int, dst, src) - def query_palette(self, handle): + def query_palette(self, handle: int | HDC | HWND) -> int: """ Installs the palette associated with the image in the given device context. @@ -135,20 +147,23 @@ def query_palette(self, handle): :param handle: Device context (HDC), cast to a Python integer, or an HDC or HWND instance. - :return: A true value if one or more entries were changed (this - indicates that the image should be redrawn). + :return: The number of entries that were changed (if one or more entries, + this indicates that the image should be redrawn). """ + handle_int = int(handle) if isinstance(handle, HWND): - handle = self.image.getdc(handle) + handle = self.image.getdc(handle_int) try: result = self.image.query_palette(handle) finally: self.image.releasedc(handle, handle) else: - result = self.image.query_palette(handle) + result = self.image.query_palette(handle_int) return result - def paste(self, im, box=None): + def paste( + self, im: Image.Image, box: tuple[int, int, int, int] | None = None + ) -> None: """ Paste a PIL image into the bitmap image. @@ -168,16 +183,16 @@ def paste(self, im, box=None): else: self.image.paste(im.im) - def frombytes(self, buffer): + def frombytes(self, buffer: bytes) -> None: """ Load display memory contents from byte data. :param buffer: A buffer containing display data (usually data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`) """ - return self.image.frombytes(buffer) + self.image.frombytes(buffer) - def tobytes(self): + def tobytes(self) -> bytes: """ Copy display memory contents to bytes object. @@ -189,42 +204,44 @@ def tobytes(self): class Window: """Create a Window with the given title size.""" - def __init__(self, title="PIL", width=None, height=None): + def __init__( + self, title: str = "PIL", width: int | None = None, height: int | None = None + ) -> None: self.hwnd = Image.core.createwindow( title, self.__dispatcher, width or 0, height or 0 ) - def __dispatcher(self, action, *args): - return getattr(self, "ui_handle_" + action)(*args) + def __dispatcher(self, action: str, *args: int) -> None: + getattr(self, f"ui_handle_{action}")(*args) - def ui_handle_clear(self, dc, x0, y0, x1, y1): + def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_damage(self, x0, y0, x1, y1): + def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_destroy(self): + def ui_handle_destroy(self) -> None: pass - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: pass - def ui_handle_resize(self, width, height): + def ui_handle_resize(self, width: int, height: int) -> None: pass - def mainloop(self): + def mainloop(self) -> None: Image.core.eventloop() class ImageWindow(Window): """Create an image window which displays the given image.""" - def __init__(self, image, title="PIL"): + def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None: if not isinstance(image, Dib): image = Dib(image) self.image = image width, height = image.size super().__init__(title, width=width, height=height) - def ui_handle_repair(self, dc, x0, y0, x1, y1): + def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None: self.image.draw(dc, (x0, y0, x1, y1)) diff --git a/dependencies/WIN/PIL/ImtImagePlugin.py b/dependencies/WIN/PIL/ImtImagePlugin.py index 21ffd74..068cd5c 100644 --- a/dependencies/WIN/PIL/ImtImagePlugin.py +++ b/dependencies/WIN/PIL/ImtImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re @@ -22,7 +22,7 @@ # # -------------------------------------------------------------------- -field = re.compile(br"([a-z]*) ([^ \r\n]*)") +field = re.compile(rb"([a-z]*) ([^ \r\n]*)") ## @@ -30,41 +30,51 @@ class ImtImageFile(ImageFile.ImageFile): - format = "IMT" format_description = "IM Tools" - def _open(self): - + def _open(self) -> None: # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. - if b"\n" not in self.fp.read(100): - raise SyntaxError("not an IM file") - self.fp.seek(0) + assert self.fp is not None + + buffer = self.fp.read(100) + if b"\n" not in buffer: + msg = "not an IM file" + raise SyntaxError(msg) xsize = ysize = 0 while True: - - s = self.fp.read(1) + if buffer: + s = buffer[:1] + buffer = buffer[1:] + else: + s = self.fp.read(1) if not s: break if s == b"\x0C": - # image data begins self.tile = [ - ("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1)) + ImageFile._Tile( + "raw", + (0, 0) + self.size, + self.fp.tell() - len(buffer), + self.mode, + ) ] break else: - # read key/value pair - # FIXME: dangerous, may read whole file - s = s + self.fp.readline() + if b"\n" not in buffer: + buffer += self.fp.read(100) + lines = buffer.split(b"\n") + s += lines.pop(0) + buffer = b"\n".join(lines) if len(s) == 1 or len(s) > 100: break if s[0] == ord(b"*"): @@ -74,14 +84,14 @@ def _open(self): if not m: break k, v = m.group(1, 2) - if k == "width": + if k == b"width": xsize = int(v) self._size = xsize, ysize - elif k == "height": + elif k == b"height": ysize = int(v) self._size = xsize, ysize - elif k == "pixel" and v == "n8": - self.mode = "L" + elif k == b"pixel" and v == b"n8": + self._mode = "L" # diff --git a/dependencies/WIN/PIL/IptcImagePlugin.py b/dependencies/WIN/PIL/IptcImagePlugin.py index 0bbe506..60ab7c8 100644 --- a/dependencies/WIN/PIL/IptcImagePlugin.py +++ b/dependencies/WIN/PIL/IptcImagePlugin.py @@ -14,31 +14,51 @@ # # See the README file for information on usage and redistribution. # -import os -import tempfile +from __future__ import annotations + +from collections.abc import Sequence +from io import BytesIO +from typing import cast from . import Image, ImageFile -from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 -from ._binary import o8 +from ._deprecate import deprecate COMPRESSION = {1: "raw", 5: "jpeg"} -PAD = o8(0) * 4 + +def __getattr__(name: str) -> bytes: + if name == "PAD": + deprecate("IptcImagePlugin.PAD", 12) + return b"\0\0\0\0" + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) # # Helpers -def i(c): - return i32((PAD + c)[-4:]) +def _i(c: bytes) -> int: + return i32((b"\0\0\0\0" + c)[-4:]) + + +def _i8(c: int | bytes) -> int: + return c if isinstance(c, int) else c[0] + + +def i(c: bytes) -> int: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.i", 12) + return _i(c) -def dump(c): +def dump(c: Sequence[int | bytes]) -> None: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.dump", 12) for i in c: - print("%02x" % i8(i), end=" ") + print(f"{_i8(i):02x}", end=" ") print() @@ -48,41 +68,41 @@ def dump(c): class IptcImageFile(ImageFile.ImageFile): - format = "IPTC" format_description = "IPTC/NAA" - def getint(self, key): - return i(self.info[key]) + def getint(self, key: tuple[int, int]) -> int: + return _i(self.info[key]) - def field(self): + def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header s = self.fp.read(5) - if not len(s): + if not s.strip(b"\x00"): return None, 0 tag = s[1], s[2] # syntax - if s[0] != 0x1C or tag[0] < 1 or tag[0] > 9: - raise SyntaxError("invalid IPTC/NAA file") + if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]: + msg = "invalid IPTC/NAA file" + raise SyntaxError(msg) # field size size = s[3] if size > 132: - raise OSError("illegal field length in IPTC/NAA file") + msg = "illegal field length in IPTC/NAA file" + raise OSError(msg) elif size == 128: size = 0 elif size > 128: - size = i(self.fp.read(size - 128)) + size = _i(self.fp.read(size - 128)) else: size = i16(s, 3) return tag, size - def _open(self): - + def _open(self) -> None: # load descriptive fields while True: offset = self.fp.tell() @@ -102,18 +122,18 @@ def _open(self): self.info[tag] = tagdata # mode - layers = i8(self.info[(3, 60)][0]) - component = i8(self.info[(3, 60)][1]) + layers = self.info[(3, 60)][0] + component = self.info[(3, 60)][1] if (3, 65) in self.info: - id = i8(self.info[(3, 65)][0]) - 1 + id = self.info[(3, 65)][0] - 1 else: id = 0 if layers == 1 and not component: - self.mode = "L" + self._mode = "L" elif layers == 3 and component: - self.mode = "RGB"[id] + self._mode = "RGB"[id] elif layers == 4 and component: - self.mode = "CMYK"[id] + self._mode = "CMYK"[id] # size self._size = self.getint((3, 20)), self.getint((3, 30)) @@ -122,32 +142,29 @@ def _open(self): try: compression = COMPRESSION[self.getint((3, 120))] except KeyError as e: - raise OSError("Unknown IPTC image compression") from e + msg = "Unknown IPTC image compression" + raise OSError(msg) from e # tile if tag == (8, 10): self.tile = [ - ("iptc", (compression, offset), (0, 0, self.size[0], self.size[1])) + ImageFile._Tile("iptc", (0, 0) + self.size, offset, compression) ] - def load(self): - + def load(self) -> Image.core.PixelAccess | None: if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) - type, tile, box = self.tile[0] - - encoding, offset = tile + offset, compression = self.tile[0][2:] self.fp.seek(offset) # Copy image data to temporary file - o_fd, outfile = tempfile.mkstemp(text=False) - o = os.fdopen(o_fd) - if encoding == "raw": + o = BytesIO() + if compression == "raw": # To simplify access to the extracted file, # prepend a PPM header - o.write("P5\n%d %d\n255\n" % self.size) + o.write(b"P5\n%d %d\n255\n" % self.size) while True: type, size = self.field() if type != (8, 10): @@ -158,17 +175,11 @@ def load(self): break o.write(s) size -= len(s) - o.close() - try: - with Image.open(outfile) as _im: - _im.load() - self.im = _im.im - finally: - try: - os.unlink(outfile) - except OSError: - pass + with Image.open(o) as _im: + _im.load() + self.im = _im.im + return None Image.register_open(IptcImageFile.format, IptcImageFile) @@ -176,7 +187,9 @@ def load(self): Image.register_extension(IptcImageFile.format, ".iim") -def getiptcinfo(im): +def getiptcinfo( + im: ImageFile.ImageFile, +) -> dict[tuple[int, int], bytes | list[bytes]] | None: """ Get IPTC information from TIFF, JPEG, or IPTC file. @@ -184,15 +197,17 @@ def getiptcinfo(im): :returns: A dictionary containing IPTC information, or None if no IPTC information block was found. """ - import io - from . import JpegImagePlugin, TiffImagePlugin data = None + info: dict[tuple[int, int], bytes | list[bytes]] = {} if isinstance(im, IptcImageFile): # return info dictionary right away - return im.info + for k, v in im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info elif isinstance(im, JpegImagePlugin.JpegImageFile): # extract the IPTC/NAA resource @@ -204,8 +219,8 @@ def getiptcinfo(im): # get raw data from the IPTC/NAA tag (PhotoShop tags the data # as 4-byte integers, so we cannot use the get method...) try: - data = im.tag.tagdata[TiffImagePlugin.IPTC_NAA_CHUNK] - except (AttributeError, KeyError): + data = im.tag_v2[TiffImagePlugin.IPTC_NAA_CHUNK] + except KeyError: pass if data is None: @@ -215,16 +230,20 @@ def getiptcinfo(im): class FakeImage: pass - im = FakeImage() - im.__class__ = IptcImageFile + fake_im = FakeImage() + fake_im.__class__ = IptcImageFile # type: ignore[assignment] + iptc_im = cast(IptcImageFile, fake_im) # parse the IPTC information chunk - im.info = {} - im.fp = io.BytesIO(data) + iptc_im.info = {} + iptc_im.fp = BytesIO(data) try: - im._open() + iptc_im._open() except (IndexError, KeyError): pass # expected failure - return im.info + for k, v in iptc_im.info.items(): + if isinstance(k, tuple): + info[k] = v + return info diff --git a/dependencies/WIN/PIL/Jpeg2KImagePlugin.py b/dependencies/WIN/PIL/Jpeg2KImagePlugin.py index cc79802..6782835 100644 --- a/dependencies/WIN/PIL/Jpeg2KImagePlugin.py +++ b/dependencies/WIN/PIL/Jpeg2KImagePlugin.py @@ -13,11 +13,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import os import struct +from collections.abc import Callable +from typing import IO, cast -from . import Image, ImageFile +from . import Image, ImageFile, ImagePalette, _binary class BoxReader: @@ -26,13 +30,13 @@ class BoxReader: and to easily step into and read sub-boxes. """ - def __init__(self, fp, length=-1): + def __init__(self, fp: IO[bytes], length: int = -1) -> None: self.fp = fp self.has_length = length >= 0 self.length = length self.remaining_in_box = -1 - def _can_read(self, num_bytes): + def _can_read(self, num_bytes: int) -> bool: if self.has_length and self.fp.tell() + num_bytes > self.length: # Outside box: ensure we don't read past the known file length return False @@ -42,76 +46,73 @@ def _can_read(self, num_bytes): else: return True # No length known, just read - def _read_bytes(self, num_bytes): + def _read_bytes(self, num_bytes: int) -> bytes: if not self._can_read(num_bytes): - raise SyntaxError("Not enough data in header") + msg = "Not enough data in header" + raise SyntaxError(msg) data = self.fp.read(num_bytes) if len(data) < num_bytes: - raise OSError( - f"Expected to read {num_bytes} bytes but only got {len(data)}." - ) + msg = f"Expected to read {num_bytes} bytes but only got {len(data)}." + raise OSError(msg) if self.remaining_in_box > 0: self.remaining_in_box -= num_bytes return data - def read_fields(self, field_format): + def read_fields(self, field_format: str) -> tuple[int | bytes, ...]: size = struct.calcsize(field_format) data = self._read_bytes(size) return struct.unpack(field_format, data) - def read_boxes(self): + def read_boxes(self) -> BoxReader: size = self.remaining_in_box data = self._read_bytes(size) return BoxReader(io.BytesIO(data), size) - def has_next_box(self): + def has_next_box(self) -> bool: if self.has_length: return self.fp.tell() + self.remaining_in_box < self.length else: return True - def next_box_type(self): + def next_box_type(self) -> bytes: # Skip the rest of the box if it has not been read if self.remaining_in_box > 0: self.fp.seek(self.remaining_in_box, os.SEEK_CUR) self.remaining_in_box = -1 # Read the length and type of the next box - lbox, tbox = self.read_fields(">I4s") + lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s")) if lbox == 1: - lbox = self.read_fields(">Q")[0] + lbox = cast(int, self.read_fields(">Q")[0]) hlen = 16 else: hlen = 8 if lbox < hlen or not self._can_read(lbox - hlen): - raise SyntaxError("Invalid header length") + msg = "Invalid header length" + raise SyntaxError(msg) self.remaining_in_box = lbox - hlen return tbox -def _parse_codestream(fp): +def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]: """Parse the JPEG 2000 codestream to extract the size and component count from the SIZ marker segment, returning a PIL (size, mode) tuple.""" hdr = fp.read(2) - lsiz = struct.unpack(">H", hdr)[0] + lsiz = _binary.i16be(hdr) siz = hdr + fp.read(lsiz - 2) lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from( ">HHIIIIIIIIH", siz ) - ssiz = [None] * csiz - xrsiz = [None] * csiz - yrsiz = [None] * csiz - for i in range(csiz): - ssiz[i], xrsiz[i], yrsiz[i] = struct.unpack_from(">BBB", siz, 36 + 3 * i) size = (xsiz - xosiz, ysiz - yosiz) if csiz == 1: - if (yrsiz[0] & 0x7F) > 8: + ssiz = struct.unpack_from(">B", siz, 38) + if (ssiz[0] & 0x7F) + 1 > 8: mode = "I;16" else: mode = "L" @@ -122,20 +123,30 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None + msg = "unable to determine J2K image mode" + raise SyntaxError(msg) - return (size, mode) + return size, mode -def _res_to_dpi(num, denom, exp): +def _res_to_dpi(num: int, denom: int, exp: int) -> float | None: """Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution, calculated as (num / denom) * 10^exp and stored in dots per meter, to floating-point dots per inch.""" - if denom != 0: - return (254 * num * (10 ** exp)) / (10000 * denom) - - -def _parse_jp2_header(fp): + if denom == 0: + return None + return (254 * num * (10**exp)) / (10000 * denom) + + +def _parse_jp2_header( + fp: IO[bytes], +) -> tuple[ + tuple[int, int], + str, + str | None, + tuple[float, float] | None, + ImagePalette.ImagePalette | None, +]: """Parse the JP2 header box to extract size, component count, color space information, and optionally DPI information, returning a (size, mode, mimetype, dpi) tuple.""" @@ -153,18 +164,23 @@ def _parse_jp2_header(fp): elif tbox == b"ftyp": if reader.read_fields(">4s")[0] == b"jpx ": mimetype = "image/jpx" + assert header is not None size = None mode = None bpc = None nc = None dpi = None # 2-tuple of DPI info, or None + palette = None while header.has_next_box(): tbox = header.next_box_type() if tbox == b"ihdr": height, width, nc, bpc = header.read_fields(">IIHB") + assert isinstance(height, int) + assert isinstance(width, int) + assert isinstance(bpc, int) size = (width, height) if nc == 1 and (bpc & 0x7F) > 8: mode = "I;16" @@ -176,12 +192,40 @@ def _parse_jp2_header(fp): mode = "RGB" elif nc == 4: mode = "RGBA" + elif tbox == b"colr" and nc == 4: + meth, _, _, enumcs = header.read_fields(">BBBI") + if meth == 1 and enumcs == 12: + mode = "CMYK" + elif tbox == b"pclr" and mode in ("L", "LA"): + ne, npc = header.read_fields(">HB") + assert isinstance(ne, int) + assert isinstance(npc, int) + max_bitdepth = 0 + for bitdepth in header.read_fields(">" + ("B" * npc)): + assert isinstance(bitdepth, int) + if bitdepth > max_bitdepth: + max_bitdepth = bitdepth + if max_bitdepth <= 8: + palette = ImagePalette.ImagePalette("RGBA" if npc == 4 else "RGB") + for i in range(ne): + color: list[int] = [] + for value in header.read_fields(">" + ("B" * npc)): + assert isinstance(value, int) + color.append(value) + palette.getcolor(tuple(color)) + mode = "P" if mode == "L" else "PA" elif tbox == b"res ": res = header.read_boxes() while res.has_next_box(): tres = res.next_box_type() if tres == b"resc": vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB") + assert isinstance(vrcn, int) + assert isinstance(vrcd, int) + assert isinstance(hrcn, int) + assert isinstance(hrcd, int) + assert isinstance(vrce, int) + assert isinstance(hrce, int) hres = _res_to_dpi(hrcn, hrcd, hrce) vres = _res_to_dpi(vrcn, vrcd, vrce) if hres is not None and vres is not None: @@ -189,9 +233,10 @@ def _parse_jp2_header(fp): break if size is None or mode is None: - raise SyntaxError("Malformed JP2 header") + msg = "Malformed JP2 header" + raise SyntaxError(msg) - return (size, mode, mimetype, dpi) + return size, mode, mimetype, dpi, palette ## @@ -202,25 +247,29 @@ class Jpeg2KImageFile(ImageFile.ImageFile): format = "JPEG2000" format_description = "JPEG 2000 (ISO 15444)" - def _open(self): + def _open(self) -> None: sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" - self._size, self.mode = _parse_codestream(self.fp) + self._size, self._mode = _parse_codestream(self.fp) + self._parse_comment() else: sig = sig + self.fp.read(8) if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a": self.codec = "jp2" header = _parse_jp2_header(self.fp) - self._size, self.mode, self.custom_mimetype, dpi = header + self._size, self._mode, self.custom_mimetype, dpi, self.palette = header if dpi is not None: self.info["dpi"] = dpi + if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"): + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + self.fp.seek(length - 2, os.SEEK_CUR) + self._parse_comment() else: - raise SyntaxError("not a JPEG 2000 file") - - if self.size is None or self.mode is None: - raise SyntaxError("unable to determine size/mode") + msg = "not a JPEG 2000 file" + raise SyntaxError(msg) self._reduce = 0 self.layers = 0 @@ -242,7 +291,7 @@ def _open(self): length = -1 self.tile = [ - ( + ImageFile._Tile( "jpeg2k", (0, 0) + self.size, 0, @@ -250,18 +299,41 @@ def _open(self): ) ] - @property - def reduce(self): + def _parse_comment(self) -> None: + while True: + marker = self.fp.read(2) + if not marker: + break + typ = marker[1] + if typ in (0x90, 0xD9): + # Start of tile or end of codestream + break + hdr = self.fp.read(2) + length = _binary.i16be(hdr) + if typ == 0x64: + # Comment + self.info["comment"] = self.fp.read(length - 2)[2:] + break + else: + self.fp.seek(length - 2, os.SEEK_CUR) + + @property # type: ignore[override] + def reduce( + self, + ) -> ( + Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image] + | int + ): # https://github.com/python-pillow/Pillow/issues/4343 found that the # new Image 'reduce' method was shadowed by this plugin's 'reduce' # property. This attempts to allow for both scenarios return self._reduce or super().reduce @reduce.setter - def reduce(self, value): + def reduce(self, value: int) -> None: self._reduce = value - def load(self): + def load(self) -> Image.core.PixelAccess | None: if self.tile and self._reduce: power = 1 << self._reduce adjust = power >> 1 @@ -272,13 +344,14 @@ def load(self): # Update the reduce and layers settings t = self.tile[0] + assert isinstance(t[3], tuple) t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) - self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] + self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return ( prefix[:4] == b"\xff\x4f\xff\x51" or prefix[:12] == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a" @@ -289,15 +362,17 @@ def _accept(prefix): # Save support -def _save(im, fp, filename): - if filename.endswith(".j2k"): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + # Get the keyword arguments + info = im.encoderinfo + + if isinstance(filename, str): + filename = filename.encode() + if filename.endswith(b".j2k") or info.get("no_jp2", False): kind = "j2k" else: kind = "jp2" - # Get the keyword arguments - info = im.encoderinfo - offset = info.get("offset", None) tile_offset = info.get("tile_offset", None) tile_size = info.get("tile_size", None) @@ -306,13 +381,11 @@ def _save(im, fp, filename): if quality_layers is not None and not ( isinstance(quality_layers, (list, tuple)) and all( - [ - isinstance(quality_layer, (int, float)) - for quality_layer in quality_layers - ] + isinstance(quality_layer, (int, float)) for quality_layer in quality_layers ) ): - raise ValueError("quality_layers must be a sequence of numbers") + msg = "quality_layers must be a sequence of numbers" + raise ValueError(msg) num_resolutions = info.get("num_resolutions", 0) cblk_size = info.get("codeblock_size", None) @@ -320,8 +393,14 @@ def _save(im, fp, filename): irreversible = info.get("irreversible", False) progression = info.get("progression", "LRCP") cinema_mode = info.get("cinema_mode", "no") - fd = -1 + mct = info.get("mct", 0) + signed = info.get("signed", False) + comment = info.get("comment") + if isinstance(comment, str): + comment = comment.encode() + plt = info.get("plt", False) + fd = -1 if hasattr(fp, "fileno"): try: fd = fp.fileno() @@ -340,10 +419,14 @@ def _save(im, fp, filename): irreversible, progression, cinema_mode, + mct, + signed, fd, + comment, + plt, ) - ImageFile._save(im, fp, [("jpeg2k", (0, 0) + im.size, 0, kind)]) + ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)]) # ------------------------------------------------------------ diff --git a/dependencies/WIN/PIL/JpegImagePlugin.py b/dependencies/WIN/PIL/JpegImagePlugin.py index b8674ee..457690a 100644 --- a/dependencies/WIN/PIL/JpegImagePlugin.py +++ b/dependencies/WIN/PIL/JpegImagePlugin.py @@ -31,6 +31,8 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import array import io import math @@ -40,23 +42,29 @@ import sys import tempfile import warnings +from typing import IO, TYPE_CHECKING, Any -from . import Image, ImageFile, TiffImagePlugin +from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import o8 +from ._binary import o16be as o16 +from ._deprecate import deprecate from .JpegPresets import presets +if TYPE_CHECKING: + from .MpoImagePlugin import MpoImageFile + # # Parser -def Skip(self, marker): +def Skip(self: JpegImageFile, marker: int) -> None: n = i16(self.fp.read(2)) - 2 ImageFile._safe_read(self.fp, n) -def APP(self, marker): +def APP(self: JpegImageFile, marker: int) -> None: # # Application marker. Store these in the APP dictionary. # Also look for well-known application markers. @@ -64,7 +72,7 @@ def APP(self, marker): n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) - app = "APP%d" % (marker & 15) + app = f"APP{marker & 15}" self.app[app] = s # compatibility self.applist.append((app, s)) @@ -82,12 +90,20 @@ def APP(self, marker): else: if jfif_unit == 1: self.info["dpi"] = jfif_density + elif jfif_unit == 2: # cm + # 1 dpcm = 2.54 dpi + self.info["dpi"] = tuple(d * 2.54 for d in jfif_density) self.info["jfif_unit"] = jfif_unit self.info["jfif_density"] = jfif_density - elif marker == 0xFFE1 and s[:5] == b"Exif\0": - if "exif" not in self.info: - # extract EXIF information (incomplete) - self.info["exif"] = s # FIXME: value will change + elif marker == 0xFFE1 and s[:6] == b"Exif\0\0": + # extract EXIF information + if "exif" in self.info: + self.info["exif"] += s[6:] + else: + self.info["exif"] = s + self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00", 1)[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -124,13 +140,14 @@ def APP(self, marker): offset += 4 data = s[offset : offset + size] if code == 0x03ED: # ResolutionInfo - data = { + photoshop[code] = { "XResolution": i32(data, 0) / 65536, "DisplayedUnitsX": i16(data, 4), "YResolution": i32(data, 8) / 65536, "DisplayedUnitsY": i16(data, 12), } - photoshop[code] = data + else: + photoshop[code] = data offset += size offset += offset & 1 # align except struct.error: @@ -152,31 +169,8 @@ def APP(self, marker): # plus constant header size self.info["mpoffset"] = self.fp.tell() - n + 4 - # If DPI isn't in JPEG header, fetch from EXIF - if "dpi" not in self.info and "exif" in self.info: - try: - exif = self.getexif() - resolution_unit = exif[0x0128] - x_resolution = exif[0x011A] - try: - dpi = float(x_resolution[0]) / x_resolution[1] - except TypeError: - dpi = x_resolution - if math.isnan(dpi): - raise ValueError - if resolution_unit == 3: # cm - # 1 dpcm = 2.54 dpi - dpi *= 2.54 - self.info["dpi"] = dpi, dpi - except (TypeError, KeyError, SyntaxError, ValueError, ZeroDivisionError): - # SyntaxError for invalid/unreadable EXIF - # KeyError for dpi not included - # ZeroDivisionError for invalid dpi rational value - # ValueError or TypeError for dpi being an invalid float - self.info["dpi"] = 72, 72 - -def COM(self, marker): +def COM(self: JpegImageFile, marker: int) -> None: # # Comment marker. Store these in the APP dictionary. n = i16(self.fp.read(2)) - 2 @@ -187,7 +181,7 @@ def COM(self, marker): self.applist.append(("COM", s)) -def SOF(self, marker): +def SOF(self: JpegImageFile, marker: int) -> None: # # Start of frame marker. Defines the size and mode of the # image. JPEG is colour blind, so we use some simple @@ -201,17 +195,19 @@ def SOF(self, marker): self.bits = s[0] if self.bits != 8: - raise SyntaxError(f"cannot handle {self.bits}-bit layers") + msg = f"cannot handle {self.bits}-bit layers" + raise SyntaxError(msg) self.layers = s[5] if self.layers == 1: - self.mode = "L" + self._mode = "L" elif self.layers == 3: - self.mode = "RGB" + self._mode = "RGB" elif self.layers == 4: - self.mode = "CMYK" + self._mode = "CMYK" else: - raise SyntaxError(f"cannot handle {self.layers}-layer images") + msg = f"cannot handle {self.layers}-layer images" + raise SyntaxError(msg) if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]: self.info["progressive"] = self.info["progression"] = 1 @@ -220,9 +216,7 @@ def SOF(self, marker): # fixup icc profile self.icclist.sort() # sort by sequence number if self.icclist[0][13] == len(self.icclist): - profile = [] - for p in self.icclist: - profile.append(p[14:]) + profile = [p[14:] for p in self.icclist] icc_profile = b"".join(profile) else: icc_profile = None # wrong number of fragments @@ -235,7 +229,7 @@ def SOF(self, marker): self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2])) -def DQT(self, marker): +def DQT(self: JpegImageFile, marker: int) -> None: # # Define quantization table. Note that there might be more # than one table in each marker. @@ -250,7 +244,8 @@ def DQT(self, marker): precision = 1 if (v // 16 == 0) else 2 # in bytes qt_length = 1 + precision * 64 if len(s) < qt_length: - raise SyntaxError("bad quantization table marker") + msg = "bad quantization table marker" + raise SyntaxError(msg) data = array.array("B" if precision == 1 else "H", s[1:qt_length]) if sys.byteorder == "little" and precision > 1: data.byteswap() # the values are always big-endian @@ -328,9 +323,9 @@ def DQT(self, marker): } -def _accept(prefix): +def _accept(prefix: bytes) -> bool: # Magic number was taken from https://en.wikipedia.org/wiki/JPEG - return prefix[0:3] == b"\xFF\xD8\xFF" + return prefix[:3] == b"\xFF\xD8\xFF" ## @@ -338,32 +333,31 @@ def _accept(prefix): class JpegImageFile(ImageFile.ImageFile): - format = "JPEG" format_description = "JPEG (ISO 10918)" - def _open(self): - + def _open(self) -> None: s = self.fp.read(3) if not _accept(s): - raise SyntaxError("not a JPEG file") + msg = "not a JPEG file" + raise SyntaxError(msg) s = b"\xFF" # Create attributes self.bits = self.layers = 0 + self._exif_offset = 0 # JPEG specifics (internal) - self.layer = [] - self.huffman_dc = {} - self.huffman_ac = {} - self.quantization = {} - self.app = {} # compatibility - self.applist = [] - self.icclist = [] + self.layer: list[tuple[int, int, int, int]] = [] + self._huffman_dc: dict[Any, Any] = {} + self._huffman_ac: dict[Any, Any] = {} + self.quantization: dict[int, list[int]] = {} + self.app: dict[str, bytes] = {} # compatibility + self.applist: list[tuple[str, bytes]] = [] + self.icclist: list[bytes] = [] while True: - i = s[0] if i == 0xFF: s = s + self.fp.read(1) @@ -381,19 +375,37 @@ def _open(self): rawmode = self.mode if self.mode == "CMYK": rawmode = "CMYK;I" # assume adobe conventions - self.tile = [("jpeg", (0, 0) + self.size, 0, (rawmode, ""))] + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, "")) + ] # self.__offset = self.fp.tell() break s = self.fp.read(1) - elif i == 0 or i == 0xFFFF: + elif i in {0, 0xFFFF}: # padded marker or junk; move on s = b"\xff" elif i == 0xFF00: # Skip extraneous data (escaped 0xFF) s = self.fp.read(1) else: - raise SyntaxError("no marker found") + msg = "no marker found" + raise SyntaxError(msg) + + self._read_dpi_from_exif() + + def __getattr__(self, name: str) -> Any: + if name in ("huffman_ac", "huffman_dc"): + deprecate(name, 12) + return getattr(self, "_" + name) + raise AttributeError(name) - def load_read(self, read_bytes): + def __getstate__(self) -> list[Any]: + return super().__getstate__() + [self.layers, self.layer] + + def __setstate__(self, state: list[Any]) -> None: + super().__setstate__(state) + self.layers, self.layer = state[5:] + + def load_read(self, read_bytes: int) -> bytes: """ internal: read more image data For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker @@ -401,28 +413,31 @@ def load_read(self, read_bytes): """ s = self.fp.read(read_bytes) - if not s and ImageFile.LOAD_TRUNCATED_IMAGES: + if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"): # Premature EOF. # Pretend file is finished adding EOI marker + self._ended = True return b"\xFF\xD9" return s - def draft(self, mode, size): - + def draft( + self, mode: str | None, size: tuple[int, int] | None + ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: - return + return None # Protect from second call if self.decoderconfig: - return + return None d, e, o, a = self.tile[0] scale = 1 original_size = self.size + assert isinstance(a, tuple) if a[0] == "RGB" and mode in ["L", "YCbCr"]: - self.mode = mode + self._mode = mode a = mode, "" if size: @@ -430,6 +445,7 @@ def draft(self, mode, size): for s in [8, 4, 2, 1]: if scale >= s: break + assert e is not None e = ( e[0], e[1], @@ -439,14 +455,13 @@ def draft(self, mode, size): self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s) scale = s - self.tile = [(d, e, o, a)] + self.tile = [ImageFile._Tile(d, e, o, a)] self.decoderconfig = (scale, 0) box = (0, 0, original_size[0] / scale, original_size[1] / scale) - return (self.mode, box) - - def load_djpeg(self): + return self.mode, box + def load_djpeg(self) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities f, path = tempfile.mkstemp() @@ -454,7 +469,13 @@ def load_djpeg(self): if os.path.exists(self.filename): subprocess.check_call(["djpeg", "-outfile", path, self.filename]) else: - raise ValueError("Invalid Filename") + try: + os.unlink(path) + except OSError: + pass + + msg = "Invalid Filename" + raise ValueError(msg) try: with Image.open(path) as _im: @@ -466,39 +487,54 @@ def load_djpeg(self): except OSError: pass - self.mode = self.im.mode + self._mode = self.im.mode self._size = self.im.size self.tile = [] - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: return _getexif(self) - def _getmp(self): - return _getmp(self) - - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - :returns: XMP tags in a dictionary. - """ + def _read_dpi_from_exif(self) -> None: + # If DPI isn't in JPEG header, fetch from EXIF + if "dpi" in self.info or "exif" not in self.info: + return + try: + exif = self.getexif() + resolution_unit = exif[0x0128] + x_resolution = exif[0x011A] + try: + dpi = float(x_resolution[0]) / x_resolution[1] + except TypeError: + dpi = x_resolution + if math.isnan(dpi): + msg = "DPI is not a number" + raise ValueError(msg) + if resolution_unit == 3: # cm + # 1 dpcm = 2.54 dpi + dpi *= 2.54 + self.info["dpi"] = dpi, dpi + except ( + struct.error, # truncated EXIF + KeyError, # dpi not included + SyntaxError, # invalid/unreadable EXIF + TypeError, # dpi is an invalid float + ValueError, # dpi is an invalid float + ZeroDivisionError, # invalid dpi rational value + ): + self.info["dpi"] = 72, 72 - for segment, content in self.applist: - if segment == "APP1": - marker, xmp_tags = content.rsplit(b"\x00", 1) - if marker == b"http://ns.adobe.com/xap/1.0/": - return self._getxmp(xmp_tags) - return {} + def _getmp(self) -> dict[int, Any] | None: + return _getmp(self) -def _getexif(self): +def _getexif(self: JpegImageFile) -> dict[int, Any] | None: if "exif" not in self.info: return None return self.getexif()._get_merged_dict() -def _getmp(self): +def _getmp(self: JpegImageFile) -> dict[int, Any] | None: # Extract MP information. This method was inspired by the "highly # experimental" _getexif version that's been in use for years now, # itself based on the ImageFileDirectory class in the TIFF plugin. @@ -513,18 +549,22 @@ def _getmp(self): head = file_contents.read(8) endianness = ">" if head[:4] == b"\x4d\x4d\x00\x2a" else "<" # process dictionary + from . import TiffImagePlugin + try: info = TiffImagePlugin.ImageFileDirectory_v2(head) file_contents.seek(info.next) info.load(file_contents) mp = dict(info) except Exception as e: - raise SyntaxError("malformed MP Index (unreadable directory)") from e + msg = "malformed MP Index (unreadable directory)" + raise SyntaxError(msg) from e # it's an error not to have a number of images try: quant = mp[0xB001] except KeyError as e: - raise SyntaxError("malformed MP Index (no number of images)") from e + msg = "malformed MP Index (no number of images)" + raise SyntaxError(msg) from e # get MP entries mpentries = [] try: @@ -546,7 +586,8 @@ def _getmp(self): if mpentryattr["ImageDataFormat"] == 0: mpentryattr["ImageDataFormat"] = "JPEG" else: - raise SyntaxError("unsupported picture format in MPO") + msg = "unsupported picture format in MPO" + raise SyntaxError(msg) mptypemap = { 0x000000: "Undefined", 0x010001: "Large Thumbnail (VGA Equivalent)", @@ -561,7 +602,8 @@ def _getmp(self): mpentries.append(mpentry) mp[0xB002] = mpentries except KeyError as e: - raise SyntaxError("malformed MP Index (bad MP Entry)") from e + msg = "malformed MP Index (bad MP Entry)" + raise SyntaxError(msg) from e # Next we should try and parse the individual image unique ID list; # we don't because I've never seen this actually used in a real MPO # file and so can't test it. @@ -600,16 +642,7 @@ def _getmp(self): # fmt: on -def convert_dict_qtables(qtables): - warnings.warn( - "convert_dict_qtables is deprecated and will be removed in Pillow 10" - "(2023-01-02). Conversion is no longer needed.", - DeprecationWarning, - ) - return qtables - - -def get_sampling(im): +def get_sampling(im: Image.Image) -> int: # There's no subsampling when images have only 1 layer # (grayscale images) or when they are CMYK (4 layers), # so set subsampling to the default value. @@ -617,18 +650,22 @@ def get_sampling(im): # NOTE: currently Pillow can't encode JPEG to YCCK format. # If YCCK support is added in the future, subsampling code will have # to be updated (here and in JpegEncode.c) to deal with 4 layers. - if not hasattr(im, "layers") or im.layers in (1, 4): + if not isinstance(im, JpegImageFile) or im.layers in (1, 4): return -1 sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3] return samplings.get(sampling, -1) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.width == 0 or im.height == 0: + msg = "cannot write empty image as JPEG" + raise ValueError(msg) try: rawmode = RAWMODE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as JPEG") from e + msg = f"cannot write mode {im.mode} as JPEG" + raise OSError(msg) from e info = im.encoderinfo @@ -648,7 +685,8 @@ def _save(im, fp, filename): subsampling = preset.get("subsampling", -1) qtables = preset.get("quantization") elif not isinstance(quality, int): - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) else: if subsampling in presets: subsampling = presets[subsampling].get("subsampling", -1) @@ -667,10 +705,15 @@ def _save(im, fp, filename): subsampling = 2 elif subsampling == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) subsampling = get_sampling(im) - def validate_qtables(qtables): + def validate_qtables( + qtables: ( + str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None + ) + ) -> list[list[int]] | None: if qtables is None: return qtables if isinstance(qtables, str): @@ -681,7 +724,8 @@ def validate_qtables(qtables): for num in line.split("#", 1)[0].split() ] except ValueError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)] if isinstance(qtables, (tuple, list, dict)): @@ -692,38 +736,52 @@ def validate_qtables(qtables): elif isinstance(qtables, tuple): qtables = list(qtables) if not (0 < len(qtables) < 5): - raise ValueError("None or too many quantization tables") + msg = "None or too many quantization tables" + raise ValueError(msg) for idx, table in enumerate(qtables): try: if len(table) != 64: - raise TypeError - table = array.array("H", table) + msg = "Invalid quantization table" + raise TypeError(msg) + table_array = array.array("H", table) except TypeError as e: - raise ValueError("Invalid quantization table") from e + msg = "Invalid quantization table" + raise ValueError(msg) from e else: - qtables[idx] = list(table) + qtables[idx] = list(table_array) return qtables if qtables == "keep": if im.format != "JPEG": - raise ValueError("Cannot use 'keep' when original image is not a JPEG") + msg = "Cannot use 'keep' when original image is not a JPEG" + raise ValueError(msg) qtables = getattr(im, "quantization", None) qtables = validate_qtables(qtables) - extra = b"" + extra = info.get("extra", b"") + + MAX_BYTES_IN_MARKER = 65533 + xmp = info.get("xmp") + if xmp: + overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00" + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len + if len(xmp) > max_data_bytes_in_marker: + msg = "XMP data is too long" + raise ValueError(msg) + size = o16(2 + overhead_len + len(xmp)) + extra += b"\xFF\xE1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp icc_profile = info.get("icc_profile") if icc_profile: - ICC_OVERHEAD_LEN = 14 - MAX_BYTES_IN_MARKER = 65533 - MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN + overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers)) + max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len markers = [] while icc_profile: - markers.append(icc_profile[:MAX_DATA_BYTES_IN_MARKER]) - icc_profile = icc_profile[MAX_DATA_BYTES_IN_MARKER:] + markers.append(icc_profile[:max_data_bytes_in_marker]) + icc_profile = icc_profile[max_data_bytes_in_marker:] i = 1 for marker in markers: - size = struct.pack(">H", 2 + ICC_OVERHEAD_LEN + len(marker)) + size = o16(2 + overhead_len + len(marker)) extra += ( b"\xFF\xE2" + size @@ -734,6 +792,8 @@ def validate_qtables(qtables): ) i += 1 + comment = info.get("comment", im.info.get("comment")) + # "progressive" is the official name, but older documentation # says "progression" # FIXME: issue a warning if the wrong form is used (post-1.1.7) @@ -744,6 +804,9 @@ def validate_qtables(qtables): exif = info.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if len(exif) > MAX_BYTES_IN_MARKER: + msg = "EXIF data is too long" + raise ValueError(msg) # get keyword arguments im.encoderconfig = ( @@ -751,11 +814,15 @@ def validate_qtables(qtables): progressive, info.get("smooth", 0), optimize, + info.get("keep_rgb", False), info.get("streamtype", 0), dpi[0], dpi[1], subsampling, + info.get("restart_marker_blocks", 0), + info.get("restart_marker_rows", 0), qtables, + comment, extra, exif, ) @@ -774,15 +841,21 @@ def validate_qtables(qtables): bufsize = 2 * im.size[0] * im.size[1] else: bufsize = im.size[0] * im.size[1] + if exif: + bufsize += len(exif) + 5 + if extra: + bufsize += len(extra) + 1 + else: + # The EXIF info needs to be written as one block, + APP1, + one spare byte. + # Ensure that our buffer is big enough. Same with the icc_profile block. + bufsize = max(bufsize, len(exif) + 5, len(extra) + 1) - # The EXIF info needs to be written as one block, + APP1, + one spare byte. - # Ensure that our buffer is big enough. Same with the icc_profile block. - bufsize = max(ImageFile.MAXBLOCK, bufsize, len(exif) + 5, len(extra) + 1) - - ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) + ImageFile._save( + im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize + ) -def _save_cjpeg(im, fp, filename): +def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities. tempfile = im._dump() subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) @@ -794,11 +867,17 @@ def _save_cjpeg(im, fp, filename): ## # Factory for making JPEG and MPO instances -def jpeg_factory(fp=None, filename=None): +def jpeg_factory( + fp: IO[bytes], filename: str | bytes | None = None +) -> JpegImageFile | MpoImageFile: im = JpegImageFile(fp, filename) try: mpheader = im._getmp() - if mpheader[45057] > 1: + if mpheader is not None and mpheader[45057] > 1: + for segment, content in im.applist: + if segment == "APP1" and b' hdrgm:Version="' in content: + # Ultra HDR images are not yet supported + return im # It's actually an MPO from .MpoImagePlugin import MpoImageFile diff --git a/dependencies/WIN/PIL/JpegPresets.py b/dependencies/WIN/PIL/JpegPresets.py index e5a5d17..d0e64a3 100644 --- a/dependencies/WIN/PIL/JpegPresets.py +++ b/dependencies/WIN/PIL/JpegPresets.py @@ -37,7 +37,7 @@ :func:`.JpegImagePlugin.get_sampling` function. In JPEG compressed data a JPEG marker is used instead of an EXIF tag. -(ref.: https://www.exiv2.org/tags.html) +(ref.: https://exiv2.org/tags.html) Quantization tables @@ -63,6 +63,8 @@ """ +from __future__ import annotations + # fmt: off presets = { 'web_low': {'subsampling': 2, # "4:2:0" diff --git a/dependencies/WIN/PIL/McIdasImagePlugin.py b/dependencies/WIN/PIL/McIdasImagePlugin.py index cd047fe..5dd031b 100644 --- a/dependencies/WIN/PIL/McIdasImagePlugin.py +++ b/dependencies/WIN/PIL/McIdasImagePlugin.py @@ -15,14 +15,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import struct from . import Image, ImageFile -def _accept(s): - return s[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" +def _accept(prefix: bytes) -> bool: + return prefix[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" ## @@ -30,16 +31,17 @@ def _accept(s): class McIdasImageFile(ImageFile.ImageFile): - format = "MCIDAS" format_description = "McIdas area file" - def _open(self): - + def _open(self) -> None: # parse area file directory + assert self.fp is not None + s = self.fp.read(256) if not _accept(s) or len(s) != 256: - raise SyntaxError("not an McIdas area file") + msg = "not an McIdas area file" + raise SyntaxError(msg) self.area_descriptor_raw = s self.area_descriptor = w = [0] + list(struct.unpack("!64i", s)) @@ -56,15 +58,18 @@ def _open(self): mode = "I" rawmode = "I;32B" else: - raise SyntaxError("unsupported McIdas format") + msg = "unsupported McIdas format" + raise SyntaxError(msg) - self.mode = mode + self._mode = mode self._size = w[10], w[9] offset = w[34] + w[15] stride = w[15] + w[10] * w[11] * w[14] - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1)) + ] # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/MicImagePlugin.py b/dependencies/WIN/PIL/MicImagePlugin.py index 9248b1b..5f23a34 100644 --- a/dependencies/WIN/PIL/MicImagePlugin.py +++ b/dependencies/WIN/PIL/MicImagePlugin.py @@ -15,7 +15,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import olefile @@ -25,7 +25,7 @@ # -------------------------------------------------------------------- -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == olefile.MAGIC @@ -34,51 +34,50 @@ def _accept(prefix): class MicImageFile(TiffImagePlugin.TiffImageFile): - format = "MIC" format_description = "Microsoft Image Composer" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: # read the OLE directory and see if this is a likely # to be a Microsoft Image Composer file try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: - raise SyntaxError("not an MIC file; invalid OLE file") from e + msg = "not an MIC file; invalid OLE file" + raise SyntaxError(msg) from e # find ACI subfiles with Image members (maybe not the # best way to identify MIC files, but what the... ;-) - self.images = [] - for path in self.ole.listdir(): - if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image": - self.images.append(path) + self.images = [ + path + for path in self.ole.listdir() + if path[1:] and path[0][-4:] == ".ACI" and path[1] == "Image" + ] # if we didn't find any images, this is probably not # an MIC file. if not self.images: - raise SyntaxError("not an MIC file; no image entries") + msg = "not an MIC file; no image entries" + raise SyntaxError(msg) - self.__fp = self.fp - self.frame = None + self.frame = -1 self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 - if len(self.images) > 1: - self._category = Image.CONTAINER - + self.__fp = self.fp self.seek(0) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return try: filename = self.images[frame] except IndexError as e: - raise EOFError("no such frame") from e + msg = "no such frame" + raise EOFError(msg) from e self.fp = self.ole.openstream(filename) @@ -86,17 +85,18 @@ def seek(self, frame): self.frame = frame - def tell(self): + def tell(self) -> int: return self.frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None + def close(self) -> None: + self.__fp.close() + self.ole.close() + super().close() + + def __exit__(self, *args: object) -> None: + self.__fp.close() + self.ole.close() + super().__exit__() # diff --git a/dependencies/WIN/PIL/MpegImagePlugin.py b/dependencies/WIN/PIL/MpegImagePlugin.py index a358dfd..ad4d3e9 100644 --- a/dependencies/WIN/PIL/MpegImagePlugin.py +++ b/dependencies/WIN/PIL/MpegImagePlugin.py @@ -12,25 +12,26 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile from ._binary import i8 +from ._typing import SupportsRead # # Bitstream parser class BitStream: - def __init__(self, fp): + def __init__(self, fp: SupportsRead[bytes]) -> None: self.fp = fp self.bits = 0 self.bitbuffer = 0 - def next(self): + def next(self) -> int: return i8(self.fp.read(1)) - def peek(self, bits): + def peek(self, bits: int) -> int: while self.bits < bits: c = self.next() if c < 0: @@ -40,43 +41,47 @@ def peek(self, bits): self.bits += 8 return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1 - def skip(self, bits): + def skip(self, bits: int) -> None: while self.bits < bits: self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1)) self.bits += 8 self.bits = self.bits - bits - def read(self, bits): + def read(self, bits: int) -> int: v = self.peek(bits) self.bits = self.bits - bits return v +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"\x00\x00\x01\xb3" + + ## # Image plugin for MPEG streams. This plugin can identify a stream, # but it cannot read it. class MpegImageFile(ImageFile.ImageFile): - format = "MPEG" format_description = "MPEG" - def _open(self): + def _open(self) -> None: + assert self.fp is not None s = BitStream(self.fp) - if s.read(32) != 0x1B3: - raise SyntaxError("not an MPEG file") + msg = "not an MPEG file" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = s.read(12), s.read(12) # -------------------------------------------------------------------- # Registry stuff -Image.register_open(MpegImageFile.format, MpegImageFile) +Image.register_open(MpegImageFile.format, MpegImageFile, _accept) Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"]) diff --git a/dependencies/WIN/PIL/MpoImagePlugin.py b/dependencies/WIN/PIL/MpoImagePlugin.py index 7ccf27c..71f89a0 100644 --- a/dependencies/WIN/PIL/MpoImagePlugin.py +++ b/dependencies/WIN/PIL/MpoImagePlugin.py @@ -17,17 +17,75 @@ # # See the README file for information on usage and redistribution. # - -from . import Image, ImageFile, JpegImagePlugin -from ._binary import i16be as i16 - -# def _accept(prefix): -# return JpegImagePlugin._accept(prefix) - - -def _save(im, fp, filename): - # Note that we can only save the current frame at present - return JpegImagePlugin._save(im, fp, filename) +from __future__ import annotations + +import itertools +import os +import struct +from typing import IO, Any, cast + +from . import ( + Image, + ImageFile, + ImageSequence, + JpegImagePlugin, + TiffImagePlugin, +) +from ._binary import o32le + + +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + JpegImagePlugin._save(im, fp, filename) + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + append_images = im.encoderinfo.get("append_images", []) + if not append_images and not getattr(im, "is_animated", False): + _save(im, fp, filename) + return + + mpf_offset = 28 + offsets: list[int] = [] + for imSequence in itertools.chain([im], append_images): + for im_frame in ImageSequence.Iterator(imSequence): + if not offsets: + # APP2 marker + im_frame.encoderinfo["extra"] = ( + b"\xFF\xE2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82 + ) + exif = im_frame.encoderinfo.get("exif") + if isinstance(exif, Image.Exif): + exif = exif.tobytes() + im_frame.encoderinfo["exif"] = exif + if exif: + mpf_offset += 4 + len(exif) + + JpegImagePlugin._save(im_frame, fp, filename) + offsets.append(fp.tell()) + else: + im_frame.save(fp, "JPEG") + offsets.append(fp.tell() - offsets[-1]) + + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[0xB000] = b"0100" + ifd[0xB001] = len(offsets) + + mpentries = b"" + data_offset = 0 + for i, size in enumerate(offsets): + if i == 0: + mptype = 0x030000 # Baseline MP Primary Image + else: + mptype = 0x000000 # Undefined + mpentries += struct.pack(" None: self.fp.seek(0) # prep the fp in order to pass the JPEG test JpegImagePlugin.JpegImageFile._open(self) self._after_jpeg_open() - def _after_jpeg_open(self, mpheader=None): + def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None: self.mpinfo = mpheader if mpheader is not None else self._getmp() + if self.mpinfo is None: + msg = "Image appears to be a malformed MPO file" + raise ValueError(msg) self.n_frames = self.mpinfo[0xB001] self.__mpoffsets = [ mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002] @@ -57,55 +117,48 @@ def _after_jpeg_open(self, mpheader=None): assert self.n_frames == len(self.__mpoffsets) del self.info["mpoffset"] # no longer needed self.is_animated = self.n_frames > 1 - self.__fp = self.fp # FIXME: hack - self.__fp.seek(self.__mpoffsets[0]) # get ready to read first frame + self._fp = self.fp # FIXME: hack + self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame self.__frame = 0 self.offset = 0 # for now we can only handle reading and individual frame extraction self.readonly = 1 - def load_seek(self, pos): - self.__fp.seek(pos) + def load_seek(self, pos: int) -> None: + self._fp.seek(pos) - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return - self.fp = self.__fp + self.fp = self._fp self.offset = self.__mpoffsets[frame] - self.fp.seek(self.offset + 2) # skip SOI marker - segment = self.fp.read(2) - if not segment: - raise ValueError("No data found for frame") - if i16(segment) == 0xFFE1: # APP1 - n = i16(self.fp.read(2)) - 2 - self.info["exif"] = ImageFile._safe_read(self.fp, n) - - mptype = self.mpinfo[0xB002][frame]["Attribute"]["MPType"] - if mptype.startswith("Large Thumbnail"): - exif = self.getexif().get_ifd(0x8769) - if 40962 in exif and 40963 in exif: - self._size = (exif[40962], exif[40963]) - elif "exif" in self.info: + original_exif = self.info.get("exif") + if "exif" in self.info: del self.info["exif"] - self.tile = [("jpeg", (0, 0) + self.size, self.offset, (self.mode, ""))] + self.fp.seek(self.offset + 2) # skip SOI marker + if not self.fp.read(2): + msg = "No data found for frame" + raise ValueError(msg) + self.fp.seek(self.offset) + JpegImagePlugin.JpegImageFile._open(self) + if self.info.get("exif") != original_exif: + self._reload_exif() + + self.tile = [ + ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1]) + ] self.__frame = frame - def tell(self): + def tell(self) -> int: return self.__frame - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - @staticmethod - def adopt(jpeg_instance, mpheader=None): + def adopt( + jpeg_instance: JpegImagePlugin.JpegImageFile, + mpheader: dict[int, Any] | None = None, + ) -> MpoImageFile: """ Transform the instance of JpegImageFile into an instance of MpoImageFile. @@ -117,8 +170,9 @@ def adopt(jpeg_instance, mpheader=None): double call to _open. """ jpeg_instance.__class__ = MpoImageFile - jpeg_instance._after_jpeg_open(mpheader) - return jpeg_instance + mpo_instance = cast(MpoImageFile, jpeg_instance) + mpo_instance._after_jpeg_open(mpheader) + return mpo_instance # --------------------------------------------------------------------- @@ -129,6 +183,7 @@ def adopt(jpeg_instance, mpheader=None): # Image.register_open(MpoImageFile.format, # JpegImagePlugin.jpeg_factory, _accept) Image.register_save(MpoImageFile.format, _save) +Image.register_save_all(MpoImageFile.format, _save_all) Image.register_extension(MpoImageFile.format, ".mpo") diff --git a/dependencies/WIN/PIL/MspImagePlugin.py b/dependencies/WIN/PIL/MspImagePlugin.py index 32b28d4..ef6ae87 100644 --- a/dependencies/WIN/PIL/MspImagePlugin.py +++ b/dependencies/WIN/PIL/MspImagePlugin.py @@ -22,9 +22,11 @@ # Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03 # # See also: https://www.fileformat.info/format/mspaint/egff.htm +from __future__ import annotations import io import struct +from typing import IO from . import Image, ImageFile from ._binary import i16le as i16 @@ -34,7 +36,7 @@ # read MSP files -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] in [b"DanM", b"LinS"] @@ -44,31 +46,33 @@ def _accept(prefix): class MspImageFile(ImageFile.ImageFile): - format = "MSP" format_description = "Windows Paint" - def _open(self): - + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an MSP file") + msg = "not an MSP file" + raise SyntaxError(msg) # Header checksum checksum = 0 for i in range(0, 32, 2): checksum = checksum ^ i16(s, i) if checksum != 0: - raise SyntaxError("bad MSP checksum") + msg = "bad MSP checksum" + raise SyntaxError(msg) - self.mode = "1" + self._mode = "1" self._size = i16(s, 4), i16(s, 6) if s[:4] == b"DanM": - self.tile = [("raw", (0, 0) + self.size, 32, ("1", 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")] else: - self.tile = [("MSP", (0, 0) + self.size, 32, None)] + self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)] class MspDecoder(ImageFile.PyDecoder): @@ -108,7 +112,8 @@ class MspDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None img = io.BytesIO() blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8)) @@ -118,7 +123,8 @@ def decode(self, buffer): f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2) ) except struct.error as e: - raise OSError("Truncated MSP file in row map") from e + msg = "Truncated MSP file in row map" + raise OSError(msg) from e for x, rowlen in enumerate(rowmap): try: @@ -127,9 +133,8 @@ def decode(self, buffer): continue row = self.fd.read(rowlen) if len(row) != rowlen: - raise OSError( - "Truncated MSP file, expected %d bytes on row %s", (rowlen, x) - ) + msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}" + raise OSError(msg) idx = 0 while idx < rowlen: runtype = row[idx] @@ -144,11 +149,12 @@ def decode(self, buffer): idx += runcount except struct.error as e: - raise OSError(f"Corrupted MSP file in row {x}") from e + msg = f"Corrupted MSP file in row {x}" + raise OSError(msg) from e - self.set_as_raw(img.getvalue(), ("1", 0, 1)) + self.set_as_raw(img.getvalue(), "1") - return 0, 0 + return -1, 0 Image.register_decoder("MSP", MspDecoder) @@ -158,10 +164,10 @@ def decode(self, buffer): # write MSP files (uncompressed only) -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as MSP") + msg = f"cannot write mode {im.mode} as MSP" + raise OSError(msg) # create MSP header header = [0] * 16 @@ -182,7 +188,7 @@ def _save(im, fp, filename): fp.write(o16(h)) # image body - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 32, ("1", 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")]) # diff --git a/dependencies/WIN/PIL/PSDraw.py b/dependencies/WIN/PIL/PSDraw.py index 743c35f..02939d2 100644 --- a/dependencies/WIN/PIL/PSDraw.py +++ b/dependencies/WIN/PIL/PSDraw.py @@ -14,8 +14,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import sys +from typing import IO, TYPE_CHECKING from . import EpsImagePlugin @@ -26,18 +28,15 @@ class PSDraw: """ Sets up printing to the given file. If ``fp`` is omitted, - ``sys.stdout.buffer`` or ``sys.stdout`` is assumed. + ``sys.stdout.buffer`` is assumed. """ - def __init__(self, fp=None): + def __init__(self, fp: IO[bytes] | None = None) -> None: if not fp: - try: - fp = sys.stdout.buffer - except AttributeError: - fp = sys.stdout + fp = sys.stdout.buffer self.fp = fp - def begin_document(self, id=None): + def begin_document(self, id: str | None = None) -> None: """Set up printing of a document. (Write PostScript DSC header.)""" # FIXME: incomplete self.fp.write( @@ -51,30 +50,32 @@ def begin_document(self, id=None): self.fp.write(EDROFF_PS) self.fp.write(VDI_PS) self.fp.write(b"%%EndProlog\n") - self.isofont = {} + self.isofont: dict[bytes, int] = {} - def end_document(self): + def end_document(self) -> None: """Ends printing. (Write PostScript DSC footer.)""" self.fp.write(b"%%EndDocument\nrestore showpage\n%%End\n") if hasattr(self.fp, "flush"): self.fp.flush() - def setfont(self, font, size): + def setfont(self, font: str, size: int) -> None: """ Selects which font to use. :param font: A PostScript font name :param size: Size in points. """ - font = bytes(font, "UTF-8") - if font not in self.isofont: + font_bytes = bytes(font, "UTF-8") + if font_bytes not in self.isofont: # reencode font - self.fp.write(b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font, font)) - self.isofont[font] = 1 + self.fp.write( + b"/PSDraw-%s ISOLatin1Encoding /%s E\n" % (font_bytes, font_bytes) + ) + self.isofont[font_bytes] = 1 # rough - self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font)) + self.fp.write(b"/F0 %d /PSDraw-%s F\n" % (size, font_bytes)) - def line(self, xy0, xy1): + def line(self, xy0: tuple[int, int], xy1: tuple[int, int]) -> None: """ Draws a line between the two points. Coordinates are given in PostScript point coordinates (72 points per inch, (0, 0) is the lower @@ -82,40 +83,38 @@ def line(self, xy0, xy1): """ self.fp.write(b"%d %d %d %d Vl\n" % (*xy0, *xy1)) - def rectangle(self, box): + def rectangle(self, box: tuple[int, int, int, int]) -> None: """ Draws a rectangle. - :param box: A 4-tuple of integers whose order and function is currently - undocumented. - - Hint: the tuple is passed into this format string: - - .. code-block:: python - - %d %d M %d %d 0 Vr\n + :param box: A tuple of four integers, specifying left, bottom, width and + height. """ - self.fp.write(b"%d %d M %d %d 0 Vr\n" % box) + self.fp.write(b"%d %d M 0 %d %d Vr\n" % box) - def text(self, xy, text): + def text(self, xy: tuple[int, int], text: str) -> None: """ Draws text at the given position. You must use :py:meth:`~PIL.PSDraw.PSDraw.setfont` before calling this method. """ - text = bytes(text, "UTF-8") - text = b"\\(".join(text.split(b"(")) - text = b"\\)".join(text.split(b")")) - xy += (text,) - self.fp.write(b"%d %d M (%s) S\n" % xy) + text_bytes = bytes(text, "UTF-8") + text_bytes = b"\\(".join(text_bytes.split(b"(")) + text_bytes = b"\\)".join(text_bytes.split(b")")) + self.fp.write(b"%d %d M (%s) S\n" % (xy + (text_bytes,))) + + if TYPE_CHECKING: + from . import Image - def image(self, box, im, dpi=None): + def image( + self, box: tuple[int, int, int, int], im: Image.Image, dpi: int | None = None + ) -> None: """Draw a PIL image, centered in the given box.""" # default resolution depends on mode if not dpi: if im.mode == "1": dpi = 200 # fax else: - dpi = 100 # greyscale + dpi = 100 # grayscale # image size (on paper) x = im.size[0] * 72 / dpi y = im.size[1] * 72 / dpi @@ -136,7 +135,7 @@ def image(self, box, im, dpi=None): sx = x / im.size[0] sy = y / im.size[1] self.fp.write(b"%f %f scale\n" % (sx, sy)) - EpsImagePlugin._save(im, self.fp, None, 0) + EpsImagePlugin._save(im, self.fp, "", 0) self.fp.write(b"\ngrestore\n") @@ -188,10 +187,10 @@ def image(self, box, im, dpi=None): /Vl { moveto lineto stroke } bind def /Vc { newpath 0 360 arc closepath } bind def /Vr { exch dup 0 rlineto - exch dup neg 0 exch rlineto + exch dup 0 exch rlineto exch neg 0 rlineto - 0 exch rlineto - 100 div setgray fill 0 setgray } bind def + 0 exch neg rlineto + setgray fill } bind def /Tm matrix def /Ve { Tm currentmatrix pop translate scale newpath 0 0 .5 0 360 arc closepath diff --git a/dependencies/WIN/PIL/PaletteFile.py b/dependencies/WIN/PIL/PaletteFile.py index 6ccaa1f..81652e5 100644 --- a/dependencies/WIN/PIL/PaletteFile.py +++ b/dependencies/WIN/PIL/PaletteFile.py @@ -12,6 +12,9 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + +from typing import IO from ._binary import o8 @@ -21,20 +24,19 @@ class PaletteFile: rawmode = "RGB" - def __init__(self, fp): - - self.palette = [(i, i, i) for i in range(256)] + def __init__(self, fp: IO[bytes]) -> None: + palette = [o8(i) * 3 for i in range(256)] while True: - s = fp.readline() if not s: break - if s[0:1] == b"#": + if s[:1] == b"#": continue if len(s) > 100: - raise SyntaxError("bad palette file") + msg = "bad palette file" + raise SyntaxError(msg) v = [int(x) for x in s.split()] try: @@ -44,10 +46,9 @@ def __init__(self, fp): g = b = r if 0 <= i <= 255: - self.palette[i] = o8(r) + o8(g) + o8(b) - - self.palette = b"".join(self.palette) + palette[i] = o8(r) + o8(g) + o8(b) - def getpalette(self): + self.palette = b"".join(palette) + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/dependencies/WIN/PIL/PalmImagePlugin.py b/dependencies/WIN/PIL/PalmImagePlugin.py index 700f10e..b332453 100644 --- a/dependencies/WIN/PIL/PalmImagePlugin.py +++ b/dependencies/WIN/PIL/PalmImagePlugin.py @@ -6,6 +6,9 @@ ## # Image plugin for Palm pixmap images (output only). ## +from __future__ import annotations + +from typing import IO from . import Image, ImageFile from ._binary import o8 @@ -81,10 +84,10 @@ # so build a prototype image to be used for palette resampling -def build_prototype_image(): +def build_prototype_image() -> Image.Image: image = Image.new("L", (1, len(_Palm8BitColormapValues))) image.putdata(list(range(len(_Palm8BitColormapValues)))) - palettedata = () + palettedata: tuple[int, ...] = () for colormapValue in _Palm8BitColormapValues: palettedata += colormapValue palettedata += (0, 0, 0) * (256 - len(_Palm8BitColormapValues)) @@ -111,10 +114,8 @@ def build_prototype_image(): # (Internal) Image save plugin for the Palm format. -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, # unless the "info" dict has a "custom-colormap" field @@ -126,35 +127,36 @@ def _save(im, fp, filename): if im.encoderinfo.get("bpp") in (1, 2, 4): # this is 8-bit grayscale, so we shift it to get the high-order bits, # and invert it because - # Palm does greyscale from white (0) to black (1) + # Palm does grayscale from white (0) to black (1) bpp = im.encoderinfo["bpp"] - im = im.point( - lambda x, shift=8 - bpp, maxval=(1 << bpp) - 1: maxval - (x >> shift) - ) + maxval = (1 << bpp) - 1 + shift = 8 - bpp + im = im.point(lambda x: maxval - (x >> shift)) elif im.info.get("bpp") in (1, 2, 4): # here we assume that even though the inherent mode is 8-bit grayscale, # only the lower bpp bits are significant. # We invert them to match the Palm. bpp = im.info["bpp"] - im = im.point(lambda x, maxval=(1 << bpp) - 1: maxval - (x & maxval)) + maxval = (1 << bpp) - 1 + im = im.point(lambda x: maxval - (x & maxval)) else: - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # we ignore the palette here - im.mode = "P" - rawmode = "P;" + str(bpp) + im._mode = "P" + rawmode = f"P;{bpp}" version = 1 elif im.mode == "1": - # monochrome -- write it inverted, as is the Palm standard rawmode = "1;I" bpp = 1 version = 0 else: - - raise OSError(f"cannot write mode {im.mode} as Palm") + msg = f"cannot write mode {im.mode} as Palm" + raise OSError(msg) # # make sure image data is available @@ -171,6 +173,7 @@ def _save(im, fp, filename): flags = 0 if im.mode == "P" and "custom-colormap" in im.info: + assert im.palette is not None flags = flags & _FLAGS["custom-colormap"] colormapsize = 4 * 256 + 2 colormapmode = im.palette.mode @@ -211,7 +214,9 @@ def _save(im, fp, filename): ) # now convert data to raw form - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))]) + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, rowbytes, 1))] + ) if hasattr(fp, "flush"): fp.flush() diff --git a/dependencies/WIN/PIL/PcdImagePlugin.py b/dependencies/WIN/PIL/PcdImagePlugin.py index 38caf5c..ac40383 100644 --- a/dependencies/WIN/PIL/PcdImagePlugin.py +++ b/dependencies/WIN/PIL/PcdImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile @@ -24,18 +24,19 @@ class PcdImageFile(ImageFile.ImageFile): - format = "PCD" format_description = "Kodak PhotoCD" - def _open(self): - + def _open(self) -> None: # rough + assert self.fp is not None + self.fp.seek(2048) s = self.fp.read(2048) if s[:4] != b"PCD_": - raise SyntaxError("not a PCD file") + msg = "not a PCD file" + raise SyntaxError(msg) orientation = s[1538] & 3 self.tile_post_rotate = None @@ -44,11 +45,11 @@ def _open(self): elif orientation == 3: self.tile_post_rotate = -90 - self.mode = "RGB" + self._mode = "RGB" self._size = 768, 512 # FIXME: not correct for rotated images! - self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)] + self.tile = [ImageFile._Tile("pcd", (0, 0) + self.size, 96 * 2048)] - def load_end(self): + def load_end(self) -> None: if self.tile_post_rotate: # Handle rotated PCDs self.im = self.im.rotate(self.tile_post_rotate) diff --git a/dependencies/WIN/PIL/PcfFontFile.py b/dependencies/WIN/PIL/PcfFontFile.py index 6a4eb22..0d1968b 100644 --- a/dependencies/WIN/PIL/PcfFontFile.py +++ b/dependencies/WIN/PIL/PcfFontFile.py @@ -15,8 +15,10 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io +from typing import BinaryIO, Callable from . import FontFile, Image from ._binary import i8 @@ -40,7 +42,7 @@ PCF_GLYPH_NAMES = 1 << 7 PCF_BDF_ACCELERATORS = 1 << 8 -BYTES_PER_ROW = [ +BYTES_PER_ROW: list[Callable[[int], int]] = [ lambda bits: ((bits + 7) >> 3), lambda bits: ((bits + 15) >> 3) & ~1, lambda bits: ((bits + 31) >> 3) & ~3, @@ -48,7 +50,7 @@ ] -def sz(s, o): +def sz(s: bytes, o: int) -> bytes: return s[o : s.index(b"\0", o)] @@ -57,13 +59,13 @@ class PcfFontFile(FontFile.FontFile): name = "name" - def __init__(self, fp, charset_encoding="iso8859-1"): - + def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"): self.charset_encoding = charset_encoding magic = l32(fp.read(4)) if magic != PCF_MAGIC: - raise SyntaxError("not a PCF file") + msg = "not a PCF file" + raise SyntaxError(msg) super().__init__() @@ -84,15 +86,28 @@ def __init__(self, fp, charset_encoding="iso8859-1"): # # create glyph structure - for ch in range(256): - ix = encoding[ch] + for ch, ix in enumerate(encoding): if ix is not None: - x, y, l, r, w, a, d, f = metrics[ix] - glyph = (w, 0), (l, d - y, x + l, d), (0, 0, x, y), bitmaps[ix] - self.glyph[ch] = glyph - - def _getformat(self, tag): - + ( + xsize, + ysize, + left, + right, + width, + ascent, + descent, + attributes, + ) = metrics[ix] + self.glyph[ch] = ( + (width, 0), + (left, descent - ysize, xsize + left, descent), + (0, 0, xsize, ysize), + bitmaps[ix], + ) + + def _getformat( + self, tag: int + ) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]: format, size, offset = self.toc[tag] fp = self.fp @@ -107,8 +122,7 @@ def _getformat(self, tag): return fp, format, i16, i32 - def _load_properties(self): - + def _load_properties(self) -> dict[bytes, bytes | int]: # # font properties @@ -119,35 +133,30 @@ def _load_properties(self): nprops = i32(fp.read(4)) # read property description - p = [] - for i in range(nprops): - p.append((i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4)))) + p = [(i32(fp.read(4)), i8(fp.read(1)), i32(fp.read(4))) for _ in range(nprops)] + if nprops & 3: fp.seek(4 - (nprops & 3), io.SEEK_CUR) # pad data = fp.read(i32(fp.read(4))) for k, s, v in p: - k = sz(data, k) - if s: - v = sz(data, v) - properties[k] = v + property_value: bytes | int = sz(data, v) if s else v + properties[sz(data, k)] = property_value return properties - def _load_metrics(self): - + def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]: # # font metrics - metrics = [] + metrics: list[tuple[int, int, int, int, int, int, int, int]] = [] fp, format, i16, i32 = self._getformat(PCF_METRICS) append = metrics.append if (format & 0xFF00) == 0x100: - # "compressed" metrics for i in range(i16(fp.read(2))): left = i8(fp.read(1)) - 128 @@ -160,7 +169,6 @@ def _load_metrics(self): append((xsize, ysize, left, right, width, ascent, descent, 0)) else: - # "jumbo" metrics for i in range(i32(fp.read(4))): left = i16(fp.read(2)) @@ -175,33 +183,29 @@ def _load_metrics(self): return metrics - def _load_bitmaps(self, metrics): - + def _load_bitmaps( + self, metrics: list[tuple[int, int, int, int, int, int, int, int]] + ) -> list[Image.Image]: # # bitmap data - bitmaps = [] - fp, format, i16, i32 = self._getformat(PCF_BITMAPS) nbitmaps = i32(fp.read(4)) if nbitmaps != len(metrics): - raise OSError("Wrong number of bitmaps") + msg = "Wrong number of bitmaps" + raise OSError(msg) - offsets = [] - for i in range(nbitmaps): - offsets.append(i32(fp.read(4))) + offsets = [i32(fp.read(4)) for _ in range(nbitmaps)] - bitmapSizes = [] - for i in range(4): - bitmapSizes.append(i32(fp.read(4))) + bitmap_sizes = [i32(fp.read(4)) for _ in range(4)] # byteorder = format & 4 # non-zero => MSB bitorder = format & 8 # non-zero => MSB padindex = format & 3 - bitmapsize = bitmapSizes[padindex] + bitmapsize = bitmap_sizes[padindex] offsets.append(bitmapsize) data = fp.read(bitmapsize) @@ -211,36 +215,38 @@ def _load_bitmaps(self, metrics): if bitorder: mode = "1" + bitmaps = [] for i in range(nbitmaps): - x, y, l, r, w, a, d, f = metrics[i] - b, e = offsets[i], offsets[i + 1] - bitmaps.append(Image.frombytes("1", (x, y), data[b:e], "raw", mode, pad(x))) + xsize, ysize = metrics[i][:2] + b, e = offsets[i : i + 2] + bitmaps.append( + Image.frombytes("1", (xsize, ysize), data[b:e], "raw", mode, pad(xsize)) + ) return bitmaps - def _load_encoding(self): - - # map character code to bitmap index - encoding = [None] * 256 - + def _load_encoding(self) -> list[int | None]: fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS) - firstCol, lastCol = i16(fp.read(2)), i16(fp.read(2)) - firstRow, lastRow = i16(fp.read(2)), i16(fp.read(2)) + first_col, last_col = i16(fp.read(2)), i16(fp.read(2)) + first_row, last_row = i16(fp.read(2)), i16(fp.read(2)) i16(fp.read(2)) # default - nencoding = (lastCol - firstCol + 1) * (lastRow - firstRow + 1) + nencoding = (last_col - first_col + 1) * (last_row - first_row + 1) + + # map character code to bitmap index + encoding: list[int | None] = [None] * min(256, nencoding) - encodingOffsets = [i16(fp.read(2)) for _ in range(nencoding)] + encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)] - for i in range(firstCol, len(encoding)): + for i in range(first_col, len(encoding)): try: - encodingOffset = encodingOffsets[ + encoding_offset = encoding_offsets[ ord(bytearray([i]).decode(self.charset_encoding)) ] - if encodingOffset != 0xFFFF: - encoding[i] = encodingOffset + if encoding_offset != 0xFFFF: + encoding[i] = encoding_offset except UnicodeDecodeError: # character is not supported in selected encoding pass diff --git a/dependencies/WIN/PIL/PcxImagePlugin.py b/dependencies/WIN/PIL/PcxImagePlugin.py index d2e166b..32436ce 100644 --- a/dependencies/WIN/PIL/PcxImagePlugin.py +++ b/dependencies/WIN/PIL/PcxImagePlugin.py @@ -24,9 +24,11 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io import logging +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -36,7 +38,7 @@ logger = logging.getLogger(__name__) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5] @@ -45,21 +47,23 @@ def _accept(prefix): class PcxImageFile(ImageFile.ImageFile): - format = "PCX" format_description = "Paintbrush" - def _open(self): - + def _open(self) -> None: # header + assert self.fp is not None + s = self.fp.read(128) if not _accept(s): - raise SyntaxError("not a PCX file") + msg = "not a PCX file" + raise SyntaxError(msg) # image bbox = i16(s, 4), i16(s, 6), i16(s, 8) + 1, i16(s, 10) + 1 if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]: - raise SyntaxError("bad PCX image size") + msg = "bad PCX image size" + raise SyntaxError(msg) logger.debug("BBox: %s %s %s %s", *bbox) # format @@ -82,7 +86,7 @@ def _open(self): elif bits == 1 and planes in (2, 4): mode = "P" - rawmode = "P;%dL" % planes + rawmode = f"P;{planes}L" self.palette = ImagePalette.raw("RGB", s[16:64]) elif version == 5 and bits == 8 and planes == 1: @@ -91,7 +95,7 @@ def _open(self): self.fp.seek(-769, io.SEEK_END) s = self.fp.read(769) if len(s) == 769 and s[0] == 12: - # check if the palette is linear greyscale + # check if the palette is linear grayscale for i in range(256): if s[i * 3 + 1 : i * 3 + 4] != o8(i) * 3: mode = rawmode = "P" @@ -105,9 +109,10 @@ def _open(self): rawmode = "RGB;L" else: - raise OSError("unknown PCX mode") + msg = "unknown PCX mode" + raise OSError(msg) - self.mode = mode + self._mode = mode self._size = bbox[2] - bbox[0], bbox[3] - bbox[1] # Don't trust the passed in stride. @@ -123,7 +128,9 @@ def _open(self): bbox = (0, 0) + self.size logger.debug("size: %sx%s", *self.size) - self.tile = [("pcx", bbox, self.fp.tell(), (rawmode, planes * stride))] + self.tile = [ + ImageFile._Tile("pcx", bbox, self.fp.tell(), (rawmode, planes * stride)) + ] # -------------------------------------------------------------------- @@ -139,12 +146,12 @@ def _open(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: - raise ValueError(f"Cannot save {im.mode} images as PCX") from e + msg = f"Cannot save {im.mode} images as PCX" + raise ValueError(msg) from e # bytes per plane stride = (im.size[0] * bits + 7) // 8 @@ -193,14 +200,18 @@ def _save(im, fp, filename): assert fp.tell() == 128 - ImageFile._save(im, fp, [("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))]) + ImageFile._save( + im, fp, [ImageFile._Tile("pcx", (0, 0) + im.size, 0, (rawmode, bits * planes))] + ) if im.mode == "P": # colour palette fp.write(o8(12)) - fp.write(im.im.getpalette("RGB", "RGB")) # 768 bytes + palette = im.im.getpalette("RGB", "RGB") + palette += b"\x00" * (768 - len(palette)) + fp.write(palette) # 768 bytes elif im.mode == "L": - # greyscale palette + # grayscale palette fp.write(o8(12)) for i in range(256): fp.write(o8(i) * 3) diff --git a/dependencies/WIN/PIL/PdfImagePlugin.py b/dependencies/WIN/PIL/PdfImagePlugin.py index 49ba077..e9c20dd 100644 --- a/dependencies/WIN/PIL/PdfImagePlugin.py +++ b/dependencies/WIN/PIL/PdfImagePlugin.py @@ -19,12 +19,15 @@ ## # Image plugin for PDF images (output only). ## +from __future__ import annotations import io +import math import os import time +from typing import IO, Any -from . import Image, ImageFile, ImageSequence, PdfParser, __version__ +from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # # -------------------------------------------------------------------- @@ -37,7 +40,7 @@ # 5. page contents -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) @@ -45,19 +48,160 @@ def _save_all(im, fp, filename): # (Internal) Image save plugin for the PDF format. -def _save(im, fp, filename, save_all=False): +def _write_image( + im: Image.Image, + filename: str | bytes, + existing_pdf: PdfParser.PdfParser, + image_refs: list[PdfParser.IndirectReference], +) -> tuple[PdfParser.IndirectReference, str]: + # FIXME: Should replace ASCIIHexDecode with RunLengthDecode + # (packbits) or LZWDecode (tiff/lzw compression). Note that + # PDF 1.2 also supports Flatedecode (zip compression). + + params = None + decode = None + + # + # Get image characteristics + + width, height = im.size + + dict_obj: dict[str, Any] = {"BitsPerComponent": 8} + if im.mode == "1": + if features.check("libtiff"): + decode_filter = "CCITTFaxDecode" + dict_obj["BitsPerComponent"] = 1 + params = PdfParser.PdfArray( + [ + PdfParser.PdfDict( + { + "K": -1, + "BlackIs1": True, + "Columns": width, + "Rows": height, + } + ) + ] + ) + else: + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "L": + decode_filter = "DCTDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray") + procset = "ImageB" # grayscale + elif im.mode == "LA": + decode_filter = "JPXDecode" + # params = f"<< /Predictor 15 /Columns {width-2} >>" + procset = "ImageB" # grayscale + dict_obj["SMaskInData"] = 1 + elif im.mode == "P": + decode_filter = "ASCIIHexDecode" + palette = im.getpalette() + assert palette is not None + dict_obj["ColorSpace"] = [ + PdfParser.PdfName("Indexed"), + PdfParser.PdfName("DeviceRGB"), + len(palette) // 3 - 1, + PdfParser.PdfBinary(palette), + ] + procset = "ImageI" # indexed color + + if "transparency" in im.info: + smask = im.convert("LA").getchannel("A") + smask.encoderinfo = {} + + image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0] + dict_obj["SMask"] = image_ref + elif im.mode == "RGB": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB") + procset = "ImageC" # color images + elif im.mode == "RGBA": + decode_filter = "JPXDecode" + procset = "ImageC" # color images + dict_obj["SMaskInData"] = 1 + elif im.mode == "CMYK": + decode_filter = "DCTDecode" + dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK") + procset = "ImageC" # color images + decode = [1, 0, 1, 0, 1, 0, 1, 0] + else: + msg = f"cannot save mode {im.mode}" + raise ValueError(msg) + + # + # image + + op = io.BytesIO() + + if decode_filter == "ASCIIHexDecode": + ImageFile._save(im, op, [ImageFile._Tile("hex", (0, 0) + im.size, 0, im.mode)]) + elif decode_filter == "CCITTFaxDecode": + im.save( + op, + "TIFF", + compression="group4", + # use a single strip + strip_size=math.ceil(width / 8) * height, + ) + elif decode_filter == "DCTDecode": + Image.SAVE["JPEG"](im, op, filename) + elif decode_filter == "JPXDecode": + del dict_obj["BitsPerComponent"] + Image.SAVE["JPEG2000"](im, op, filename) + else: + msg = f"unsupported PDF filter ({decode_filter})" + raise ValueError(msg) + + stream = op.getvalue() + filter: PdfParser.PdfArray | PdfParser.PdfName + if decode_filter == "CCITTFaxDecode": + stream = stream[8:] + filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)]) + else: + filter = PdfParser.PdfName(decode_filter) + + image_ref = image_refs.pop(0) + existing_pdf.write_obj( + image_ref, + stream=stream, + Type=PdfParser.PdfName("XObject"), + Subtype=PdfParser.PdfName("Image"), + Width=width, # * 72.0 / x_resolution, + Height=height, # * 72.0 / y_resolution, + Filter=filter, + Decode=decode, + DecodeParms=params, + **dict_obj, + ) + + return image_ref, procset + + +def _save( + im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False +) -> None: is_appending = im.encoderinfo.get("append", False) + filename_str = filename.decode() if isinstance(filename, bytes) else filename if is_appending: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="r+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="r+b") else: - existing_pdf = PdfParser.PdfParser(f=fp, filename=filename, mode="w+b") + existing_pdf = PdfParser.PdfParser(f=fp, filename=filename_str, mode="w+b") - resolution = im.encoderinfo.get("resolution", 72.0) + dpi = im.encoderinfo.get("dpi") + if dpi: + x_resolution = dpi[0] + y_resolution = dpi[1] + else: + x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) info = { - "title": None - if is_appending - else os.path.splitext(os.path.basename(filename))[0], + "title": ( + None if is_appending else os.path.splitext(os.path.basename(filename))[0] + ), "author": None, "subject": None, "keywords": None, @@ -87,22 +231,20 @@ def _save(im, fp, filename, save_all=False): for append_im in append_images: append_im.encoderinfo = im.encoderinfo.copy() ims.append(append_im) - numberOfPages = 0 + number_of_pages = 0 image_refs = [] page_refs = [] contents_refs = [] for im in ims: - im_numberOfPages = 1 + im_number_of_pages = 1 if save_all: - try: - im_numberOfPages = im.n_frames - except AttributeError: - # Image format does not have n_frames. - # It is a single frame image - pass - numberOfPages += im_numberOfPages - for i in range(im_numberOfPages): + im_number_of_pages = getattr(im, "n_frames", 1) + number_of_pages += im_number_of_pages + for i in range(im_number_of_pages): image_refs.append(existing_pdf.next_object_id(0)) + if im.mode == "P" and "transparency" in im.info: + image_refs.append(existing_pdf.next_object_id(0)) + page_refs.append(existing_pdf.next_object_id(0)) contents_refs.append(existing_pdf.next_object_id(0)) existing_pdf.pages.append(page_refs[-1]) @@ -111,114 +253,43 @@ def _save(im, fp, filename, save_all=False): # catalog and list of pages existing_pdf.write_catalog() - pageNumber = 0 - for imSequence in ims: - im_pages = ImageSequence.Iterator(imSequence) if save_all else [imSequence] + page_number = 0 + for im_sequence in ims: + im_pages: ImageSequence.Iterator | list[Image.Image] = ( + ImageSequence.Iterator(im_sequence) if save_all else [im_sequence] + ) for im in im_pages: - # FIXME: Should replace ASCIIHexDecode with RunLengthDecode - # (packbits) or LZWDecode (tiff/lzw compression). Note that - # PDF 1.2 also supports Flatedecode (zip compression). - - bits = 8 - params = None - decode = None - - if im.mode == "1": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - bits = 1 - elif im.mode == "L": - filter = "DCTDecode" - # params = f"<< /Predictor 15 /Columns {width-2} >>" - colorspace = PdfParser.PdfName("DeviceGray") - procset = "ImageB" # grayscale - elif im.mode == "P": - filter = "ASCIIHexDecode" - palette = im.im.getpalette("RGB") - colorspace = [ - PdfParser.PdfName("Indexed"), - PdfParser.PdfName("DeviceRGB"), - 255, - PdfParser.PdfBinary(palette), - ] - procset = "ImageI" # indexed color - elif im.mode == "RGB": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceRGB") - procset = "ImageC" # color images - elif im.mode == "CMYK": - filter = "DCTDecode" - colorspace = PdfParser.PdfName("DeviceCMYK") - procset = "ImageC" # color images - decode = [1, 0, 1, 0, 1, 0, 1, 0] - else: - raise ValueError(f"cannot save mode {im.mode}") - - # - # image - - op = io.BytesIO() - - if filter == "ASCIIHexDecode": - ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)]) - elif filter == "DCTDecode": - Image.SAVE["JPEG"](im, op, filename) - elif filter == "FlateDecode": - ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)]) - elif filter == "RunLengthDecode": - ImageFile._save(im, op, [("packbits", (0, 0) + im.size, 0, im.mode)]) - else: - raise ValueError(f"unsupported PDF filter ({filter})") - - # - # Get image characteristics - - width, height = im.size - - existing_pdf.write_obj( - image_refs[pageNumber], - stream=op.getvalue(), - Type=PdfParser.PdfName("XObject"), - Subtype=PdfParser.PdfName("Image"), - Width=width, # * 72.0 / resolution, - Height=height, # * 72.0 / resolution, - Filter=PdfParser.PdfName(filter), - BitsPerComponent=bits, - Decode=decode, - DecodeParams=params, - ColorSpace=colorspace, - ) + image_ref, procset = _write_image(im, filename, existing_pdf, image_refs) # # page existing_pdf.write_page( - page_refs[pageNumber], + page_refs[page_number], Resources=PdfParser.PdfDict( ProcSet=[PdfParser.PdfName("PDF"), PdfParser.PdfName(procset)], - XObject=PdfParser.PdfDict(image=image_refs[pageNumber]), + XObject=PdfParser.PdfDict(image=image_ref), ), MediaBox=[ 0, 0, - width * 72.0 / resolution, - height * 72.0 / resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ], - Contents=contents_refs[pageNumber], + Contents=contents_refs[page_number], ) # # page contents page_contents = b"q %f 0 0 %f 0 0 cm /image Do Q\n" % ( - width * 72.0 / resolution, - height * 72.0 / resolution, + im.width * 72.0 / x_resolution, + im.height * 72.0 / y_resolution, ) - existing_pdf.write_obj(contents_refs[pageNumber], stream=page_contents) + existing_pdf.write_obj(contents_refs[page_number], stream=page_contents) - pageNumber += 1 + page_number += 1 # # trailer diff --git a/dependencies/WIN/PIL/PdfParser.py b/dependencies/WIN/PIL/PdfParser.py index b5279e0..7cb2d24 100644 --- a/dependencies/WIN/PIL/PdfParser.py +++ b/dependencies/WIN/PIL/PdfParser.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import calendar import codecs import collections @@ -6,11 +8,12 @@ import re import time import zlib +from typing import IO, TYPE_CHECKING, Any, NamedTuple, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set # on page 656 -def encode_text(s): +def encode_text(s: str) -> bytes: return codecs.BOM_UTF16_BE + s.encode("utf_16_be") @@ -59,7 +62,7 @@ def encode_text(s): } -def decode_text(b): +def decode_text(b: bytes) -> str: if b[: len(codecs.BOM_UTF16_BE)] == codecs.BOM_UTF16_BE: return b[len(codecs.BOM_UTF16_BE) :].decode("utf_16_be") else: @@ -73,47 +76,53 @@ class PdfFormatError(RuntimeError): pass -def check_format_condition(condition, error_message): +def check_format_condition(condition: bool, error_message: str) -> None: if not condition: raise PdfFormatError(error_message) -class IndirectReference( - collections.namedtuple("IndirectReferenceTuple", ["object_id", "generation"]) -): - def __str__(self): - return "%s %s R" % self +class IndirectReferenceTuple(NamedTuple): + object_id: int + generation: int + - def __bytes__(self): +class IndirectReference(IndirectReferenceTuple): + def __str__(self) -> str: + return f"{self.object_id} {self.generation} R" + + def __bytes__(self) -> bytes: return self.__str__().encode("us-ascii") - def __eq__(self, other): - return ( - other.__class__ is self.__class__ - and other.object_id == self.object_id - and other.generation == self.generation - ) + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, IndirectReference) + return other.object_id == self.object_id and other.generation == self.generation - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.object_id, self.generation)) class IndirectObjectDef(IndirectReference): - def __str__(self): - return "%s %s obj" % self + def __str__(self) -> str: + return f"{self.object_id} {self.generation} obj" class XrefTable: - def __init__(self): - self.existing_entries = {} # object ID => (offset, generation) - self.new_entries = {} # object ID => (offset, generation) + def __init__(self) -> None: + self.existing_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) + self.new_entries: dict[int, tuple[int, int]] = ( + {} + ) # object ID => (offset, generation) self.deleted_entries = {0: 65536} # object ID => generation self.reading_finished = False - def __setitem__(self, key, value): + def __setitem__(self, key: int, value: tuple[int, int]) -> None: if self.reading_finished: self.new_entries[key] = value else: @@ -121,13 +130,13 @@ def __setitem__(self, key, value): if key in self.deleted_entries: del self.deleted_entries[key] - def __getitem__(self, key): + def __getitem__(self, key: int) -> tuple[int, int]: try: return self.new_entries[key] except KeyError: return self.existing_entries[key] - def __delitem__(self, key): + def __delitem__(self, key: int) -> None: if key in self.new_entries: generation = self.new_entries[key][1] + 1 del self.new_entries[key] @@ -138,33 +147,32 @@ def __delitem__(self, key): elif key in self.deleted_entries: generation = self.deleted_entries[key] else: - raise IndexError( - "object ID " + str(key) + " cannot be deleted because it doesn't exist" - ) + msg = f"object ID {key} cannot be deleted because it doesn't exist" + raise IndexError(msg) - def __contains__(self, key): + def __contains__(self, key: int) -> bool: return key in self.existing_entries or key in self.new_entries - def __len__(self): + def __len__(self) -> int: return len( set(self.existing_entries.keys()) | set(self.new_entries.keys()) | set(self.deleted_entries.keys()) ) - def keys(self): + def keys(self) -> set[int]: return ( set(self.existing_entries.keys()) - set(self.deleted_entries.keys()) ) | set(self.new_entries.keys()) - def write(self, f): + def write(self, f: IO[bytes]) -> int: keys = sorted(set(self.new_entries.keys()) | set(self.deleted_entries.keys())) deleted_keys = sorted(set(self.deleted_entries.keys())) startxref = f.tell() f.write(b"xref\n") while keys: # find a contiguous sequence of object IDs - prev = None + prev: int | None = None for index, key in enumerate(keys): if prev is None or prev + 1 == key: prev = key @@ -174,7 +182,7 @@ def write(self, f): break else: contiguous_keys = keys - keys = None + keys = [] f.write(b"%d %d\n" % (contiguous_keys[0], len(contiguous_keys))) for object_id in contiguous_keys: if object_id in self.new_entries: @@ -198,7 +206,9 @@ def write(self, f): class PdfName: - def __init__(self, name): + name: bytes + + def __init__(self, name: PdfName | bytes | str) -> None: if isinstance(name, PdfName): self.name = name.name elif isinstance(name, bytes): @@ -206,27 +216,27 @@ def __init__(self, name): else: self.name = name.encode("us-ascii") - def name_as_str(self): + def name_as_str(self) -> str: return self.name.decode("us-ascii") - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return ( isinstance(other, PdfName) and other.name == self.name ) or other == self.name - def __hash__(self): + def __hash__(self) -> int: return hash(self.name) - def __repr__(self): - return f"PdfName({repr(self.name)})" + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self.name)})" @classmethod - def from_pdf_stream(cls, data): + def from_pdf_stream(cls, data: bytes) -> PdfName: return cls(PdfParser.interpret_name(data)) allowed_chars = set(range(33, 127)) - {ord(c) for c in "#%/()<>[]{}"} - def __bytes__(self): + def __bytes__(self) -> bytes: result = bytearray(b"/") for b in self.name: if b in self.allowed_chars: @@ -236,19 +246,25 @@ def __bytes__(self): return bytes(result) -class PdfArray(list): - def __bytes__(self): +class PdfArray(list[Any]): + def __bytes__(self) -> bytes: return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" -class PdfDict(collections.UserDict): - def __setattr__(self, key, value): +if TYPE_CHECKING: + _DictBase = collections.UserDict[Union[str, bytes], Any] +else: + _DictBase = collections.UserDict + + +class PdfDict(_DictBase): + def __setattr__(self, key: str, value: Any) -> None: if key == "data": collections.UserDict.__setattr__(self, key, value) else: self[key.encode("us-ascii")] = value - def __getattr__(self, key): + def __getattr__(self, key: str) -> str | time.struct_time: try: value = self[key.encode("us-ascii")] except KeyError as e: @@ -275,7 +291,7 @@ def __getattr__(self, key): value = time.gmtime(calendar.timegm(value) + offset) return value - def __bytes__(self): + def __bytes__(self) -> bytes: out = bytearray(b"<<") for key, value in self.items(): if value is None: @@ -290,36 +306,35 @@ def __bytes__(self): class PdfBinary: - def __init__(self, data): + def __init__(self, data: list[int] | bytes) -> None: self.data = data - def __bytes__(self): + def __bytes__(self) -> bytes: return b"<%s>" % b"".join(b"%02X" % b for b in self.data) class PdfStream: - def __init__(self, dictionary, buf): + def __init__(self, dictionary: PdfDict, buf: bytes) -> None: self.dictionary = dictionary self.buf = buf - def decode(self): + def decode(self) -> bytes: try: - filter = self.dictionary.Filter - except AttributeError: + filter = self.dictionary[b"Filter"] + except KeyError: return self.buf if filter == b"FlateDecode": try: - expected_length = self.dictionary.DL - except AttributeError: - expected_length = self.dictionary.Length + expected_length = self.dictionary[b"DL"] + except KeyError: + expected_length = self.dictionary[b"Length"] return zlib.decompress(self.buf, bufsize=int(expected_length)) else: - raise NotImplementedError( - f"stream filter {repr(self.dictionary.Filter)} unknown/unsupported" - ) + msg = f"stream filter {repr(filter)} unknown/unsupported" + raise NotImplementedError(msg) -def pdf_repr(x): +def pdf_repr(x: Any) -> bytes: if x is True: return b"true" elif x is False: @@ -328,9 +343,7 @@ def pdf_repr(x): return b"null" elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)): return bytes(x) - elif isinstance(x, int): - return str(x).encode("us-ascii") - elif isinstance(x, float): + elif isinstance(x, (int, float)): return str(x).encode("us-ascii") elif isinstance(x, time.struct_time): return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")" @@ -356,11 +369,19 @@ class PdfParser: Supports PDF up to 1.4 """ - def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): + def __init__( + self, + filename: str | None = None, + f: IO[bytes] | None = None, + buf: bytes | bytearray | None = None, + start_offset: int = 0, + mode: str = "rb", + ) -> None: if buf and f: - raise RuntimeError("specify buf or f or filename, but not both buf and f") + msg = "specify buf or f or filename, but not both buf and f" + raise RuntimeError(msg) self.filename = filename - self.buf = buf + self.buf: bytes | bytearray | mmap.mmap | None = buf self.f = f self.start_offset = start_offset self.should_close_buf = False @@ -369,12 +390,16 @@ def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): self.f = f = open(filename, mode) self.should_close_file = True if f is not None: - self.buf = buf = self.get_buf_from_file(f) + self.buf = self.get_buf_from_file(f) self.should_close_buf = True if not filename and hasattr(f, "name"): self.filename = f.name - self.cached_objects = {} - if buf: + self.cached_objects: dict[IndirectReference, Any] = {} + self.root_ref: IndirectReference | None + self.info_ref: IndirectReference | None + self.pages_ref: IndirectReference | None + self.last_xref_section_offset: int | None + if self.buf: self.read_pdf_info() else: self.file_size_total = self.file_size_this = 0 @@ -382,52 +407,53 @@ def __init__(self, filename=None, f=None, buf=None, start_offset=0, mode="rb"): self.root_ref = None self.info = PdfDict() self.info_ref = None - self.page_tree_root = {} - self.pages = [] - self.orig_pages = [] + self.page_tree_root = PdfDict() + self.pages: list[IndirectReference] = [] + self.orig_pages: list[IndirectReference] = [] self.pages_ref = None self.last_xref_section_offset = None - self.trailer_dict = {} + self.trailer_dict: dict[bytes, Any] = {} self.xref_table = XrefTable() self.xref_table.reading_finished = True if f: self.seek_end() - def __enter__(self): + def __enter__(self) -> PdfParser: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: self.close() - return False # do not suppress exceptions - def start_writing(self): + def start_writing(self) -> None: self.close_buf() self.seek_end() - def close_buf(self): - try: + def close_buf(self) -> None: + if isinstance(self.buf, mmap.mmap): self.buf.close() - except AttributeError: - pass self.buf = None - def close(self): + def close(self) -> None: if self.should_close_buf: self.close_buf() if self.f is not None and self.should_close_file: self.f.close() self.f = None - def seek_end(self): + def seek_end(self) -> None: + assert self.f is not None self.f.seek(0, os.SEEK_END) - def write_header(self): + def write_header(self) -> None: + assert self.f is not None self.f.write(b"%PDF-1.4\n") - def write_comment(self, s): - self.f.write(f"% {s}\n".encode("utf-8")) + def write_comment(self, s: str) -> None: + assert self.f is not None + self.f.write(f"% {s}\n".encode()) - def write_catalog(self): + def write_catalog(self) -> IndirectReference: + assert self.f is not None self.del_root() self.root_ref = self.next_object_id(self.f.tell()) self.pages_ref = self.next_object_id(0) @@ -441,7 +467,7 @@ def write_catalog(self): ) return self.root_ref - def rewrite_pages(self): + def rewrite_pages(self) -> None: pages_tree_nodes_to_delete = [] for i, page_ref in enumerate(self.orig_pages): page_info = self.cached_objects[page_ref] @@ -470,7 +496,10 @@ def rewrite_pages(self): pages_tree_node_ref = pages_tree_node.get(b"Parent", None) self.orig_pages = [] - def write_xref_and_trailer(self, new_root_ref=None): + def write_xref_and_trailer( + self, new_root_ref: IndirectReference | None = None + ) -> None: + assert self.f is not None if new_root_ref: self.del_root() self.root_ref = new_root_ref @@ -478,7 +507,10 @@ def write_xref_and_trailer(self, new_root_ref=None): self.info_ref = self.write_obj(None, self.info) start_xref = self.xref_table.write(self.f) num_entries = len(self.xref_table) - trailer_dict = {b"Root": self.root_ref, b"Size": num_entries} + trailer_dict: dict[str | bytes, Any] = { + b"Root": self.root_ref, + b"Size": num_entries, + } if self.last_xref_section_offset is not None: trailer_dict[b"Prev"] = self.last_xref_section_offset if self.info: @@ -490,16 +522,20 @@ def write_xref_and_trailer(self, new_root_ref=None): + b"\nstartxref\n%d\n%%%%EOF" % start_xref ) - def write_page(self, ref, *objs, **dict_obj): - if isinstance(ref, int): - ref = self.pages[ref] + def write_page( + self, ref: int | IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + obj_ref = self.pages[ref] if isinstance(ref, int) else ref if "Type" not in dict_obj: dict_obj["Type"] = PdfName(b"Page") if "Parent" not in dict_obj: dict_obj["Parent"] = self.pages_ref - return self.write_obj(ref, *objs, **dict_obj) + return self.write_obj(obj_ref, *objs, **dict_obj) - def write_obj(self, ref, *objs, **dict_obj): + def write_obj( + self, ref: IndirectReference | None, *objs: Any, **dict_obj: Any + ) -> IndirectReference: + assert self.f is not None f = self.f if ref is None: ref = self.next_object_id(f.tell()) @@ -520,14 +556,14 @@ def write_obj(self, ref, *objs, **dict_obj): f.write(b"endobj\n") return ref - def del_root(self): + def del_root(self) -> None: if self.root_ref is None: return del self.xref_table[self.root_ref.object_id] del self.xref_table[self.root[b"Pages"].object_id] @staticmethod - def get_buf_from_file(f): + def get_buf_from_file(f: IO[bytes]) -> bytes | mmap.mmap: if hasattr(f, "getbuffer"): return f.getbuffer() elif hasattr(f, "getvalue"): @@ -538,11 +574,16 @@ def get_buf_from_file(f): except ValueError: # cannot mmap an empty file return b"" - def read_pdf_info(self): + def read_pdf_info(self) -> None: + assert self.buf is not None self.file_size_total = len(self.buf) self.file_size_this = self.file_size_total - self.start_offset self.read_trailer() + check_format_condition( + self.trailer_dict.get(b"Root") is not None, "Root is missing" + ) self.root_ref = self.trailer_dict[b"Root"] + assert self.root_ref is not None self.info_ref = self.trailer_dict.get(b"Info", None) self.root = PdfDict(self.read_indirect(self.root_ref)) if self.info_ref is None: @@ -553,12 +594,15 @@ def read_pdf_info(self): check_format_condition( self.root[b"Type"] == b"Catalog", "/Type in Root is not /Catalog" ) - check_format_condition(b"Pages" in self.root, "/Pages missing in Root") + check_format_condition( + self.root.get(b"Pages") is not None, "/Pages missing in Root" + ) check_format_condition( isinstance(self.root[b"Pages"], IndirectReference), "/Pages in Root is not an indirect reference", ) self.pages_ref = self.root[b"Pages"] + assert self.pages_ref is not None self.page_tree_root = self.read_indirect(self.pages_ref) self.pages = self.linearize_page_tree(self.page_tree_root) # save the original list of page references @@ -566,7 +610,7 @@ def read_pdf_info(self): # and we need to rewrite the pages and their list self.orig_pages = self.pages[:] - def next_object_id(self, offset=None): + def next_object_id(self, offset: int | None = None) -> IndirectReference: try: # TODO: support reuse of deleted objects reference = IndirectReference(max(self.xref_table.keys()) + 1, 0) @@ -576,51 +620,53 @@ def next_object_id(self, offset=None): self.xref_table[reference.object_id] = (offset, 0) return reference - delimiter = br"[][()<>{}/%]" - delimiter_or_ws = br"[][()<>{}/%\000\011\012\014\015\040]" - whitespace = br"[\000\011\012\014\015\040]" - whitespace_or_hex = br"[\000\011\012\014\015\0400-9a-fA-F]" + delimiter = rb"[][()<>{}/%]" + delimiter_or_ws = rb"[][()<>{}/%\000\011\012\014\015\040]" + whitespace = rb"[\000\011\012\014\015\040]" + whitespace_or_hex = rb"[\000\011\012\014\015\0400-9a-fA-F]" whitespace_optional = whitespace + b"*" whitespace_mandatory = whitespace + b"+" - whitespace_optional_no_nl = br"[\000\011\014\015\040]*" # no "\012" aka "\n" - newline_only = br"[\r\n]+" + # No "\012" aka "\n" or "\015" aka "\r": + whitespace_optional_no_nl = rb"[\000\011\014\040]*" + newline_only = rb"[\r\n]+" newline = whitespace_optional_no_nl + newline_only + whitespace_optional_no_nl re_trailer_end = re.compile( whitespace_mandatory - + br"trailer" + + rb"trailer" + whitespace_optional - + br"\<\<(.*\>\>)" + + rb"<<(.*>>)" + newline - + br"startxref" + + rb"startxref" + newline - + br"([0-9]+)" + + rb"([0-9]+)" + newline - + br"%%EOF" + + rb"%%EOF" + whitespace_optional - + br"$", + + rb"$", re.DOTALL, ) re_trailer_prev = re.compile( whitespace_optional - + br"trailer" + + rb"trailer" + whitespace_optional - + br"\<\<(.*?\>\>)" + + rb"<<(.*?>>)" + newline - + br"startxref" + + rb"startxref" + newline - + br"([0-9]+)" + + rb"([0-9]+)" + newline - + br"%%EOF" + + rb"%%EOF" + whitespace_optional, re.DOTALL, ) - def read_trailer(self): + def read_trailer(self) -> None: + assert self.buf is not None search_start_offset = len(self.buf) - 16384 if search_start_offset < self.start_offset: search_start_offset = self.start_offset m = self.re_trailer_end.search(self.buf, search_start_offset) - check_format_condition(m, "trailer end not found") + check_format_condition(m is not None, "trailer end not found") # make sure we found the LAST trailer last_match = m while m: @@ -628,6 +674,7 @@ def read_trailer(self): m = self.re_trailer_end.search(self.buf, m.start() + 16) if not m: m = last_match + assert m is not None trailer_data = m.group(1) self.last_xref_section_offset = int(m.group(2)) self.trailer_dict = self.interpret_trailer(trailer_data) @@ -636,12 +683,14 @@ def read_trailer(self): if b"Prev" in self.trailer_dict: self.read_prev_trailer(self.trailer_dict[b"Prev"]) - def read_prev_trailer(self, xref_section_offset): + def read_prev_trailer(self, xref_section_offset: int) -> None: + assert self.buf is not None trailer_offset = self.read_xref_table(xref_section_offset=xref_section_offset) m = self.re_trailer_prev.search( self.buf[trailer_offset : trailer_offset + 16384] ) - check_format_condition(m, "previous trailer not found") + check_format_condition(m is not None, "previous trailer not found") + assert m is not None trailer_data = m.group(1) check_format_condition( int(m.group(2)) == xref_section_offset, @@ -654,15 +703,15 @@ def read_prev_trailer(self, xref_section_offset): re_whitespace_optional = re.compile(whitespace_optional) re_name = re.compile( whitespace_optional - + br"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?=" + + rb"/([!-$&'*-.0-;=?-Z\\^-z|~]+)(?=" + delimiter_or_ws - + br")" + + rb")" ) - re_dict_start = re.compile(whitespace_optional + br"\<\<") - re_dict_end = re.compile(whitespace_optional + br"\>\>" + whitespace_optional) + re_dict_start = re.compile(whitespace_optional + rb"<<") + re_dict_end = re.compile(whitespace_optional + rb">>" + whitespace_optional) @classmethod - def interpret_trailer(cls, trailer_data): + def interpret_trailer(cls, trailer_data: bytes) -> dict[bytes, Any]: trailer = {} offset = 0 while True: @@ -670,14 +719,18 @@ def interpret_trailer(cls, trailer_data): if not m: m = cls.re_dict_end.match(trailer_data, offset) check_format_condition( - m and m.end() == len(trailer_data), + m is not None and m.end() == len(trailer_data), "name not found in trailer, remaining data: " + repr(trailer_data[offset:]), ) break key = cls.interpret_name(m.group(1)) - value, offset = cls.get_value(trailer_data, m.end()) + assert isinstance(key, bytes) + value, value_offset = cls.get_value(trailer_data, m.end()) trailer[key] = value + if value_offset is None: + break + offset = value_offset check_format_condition( b"Size" in trailer and isinstance(trailer[b"Size"], int), "/Size not in trailer or not an integer", @@ -688,10 +741,10 @@ def interpret_trailer(cls, trailer_data): ) return trailer - re_hashes_in_name = re.compile(br"([^#]*)(#([0-9a-fA-F]{2}))?") + re_hashes_in_name = re.compile(rb"([^#]*)(#([0-9a-fA-F]{2}))?") @classmethod - def interpret_name(cls, raw, as_text=False): + def interpret_name(cls, raw: bytes, as_text: bool = False) -> str | bytes: name = b"" for m in cls.re_hashes_in_name.finditer(raw): if m.group(3): @@ -703,57 +756,63 @@ def interpret_name(cls, raw, as_text=False): else: return bytes(name) - re_null = re.compile(whitespace_optional + br"null(?=" + delimiter_or_ws + br")") - re_true = re.compile(whitespace_optional + br"true(?=" + delimiter_or_ws + br")") - re_false = re.compile(whitespace_optional + br"false(?=" + delimiter_or_ws + br")") + re_null = re.compile(whitespace_optional + rb"null(?=" + delimiter_or_ws + rb")") + re_true = re.compile(whitespace_optional + rb"true(?=" + delimiter_or_ws + rb")") + re_false = re.compile(whitespace_optional + rb"false(?=" + delimiter_or_ws + rb")") re_int = re.compile( - whitespace_optional + br"([-+]?[0-9]+)(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"([-+]?[0-9]+)(?=" + delimiter_or_ws + rb")" ) re_real = re.compile( whitespace_optional - + br"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?=" + + rb"([-+]?([0-9]+\.[0-9]*|[0-9]*\.[0-9]+))(?=" + delimiter_or_ws - + br")" + + rb")" ) - re_array_start = re.compile(whitespace_optional + br"\[") - re_array_end = re.compile(whitespace_optional + br"]") + re_array_start = re.compile(whitespace_optional + rb"\[") + re_array_end = re.compile(whitespace_optional + rb"]") re_string_hex = re.compile( - whitespace_optional + br"\<(" + whitespace_or_hex + br"*)\>" + whitespace_optional + rb"<(" + whitespace_or_hex + rb"*)>" ) - re_string_lit = re.compile(whitespace_optional + br"\(") + re_string_lit = re.compile(whitespace_optional + rb"\(") re_indirect_reference = re.compile( whitespace_optional - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"R(?=" + + rb"R(?=" + delimiter_or_ws - + br")" + + rb")" ) re_indirect_def_start = re.compile( whitespace_optional - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"([-+]?[0-9]+)" + + rb"([-+]?[0-9]+)" + whitespace_mandatory - + br"obj(?=" + + rb"obj(?=" + delimiter_or_ws - + br")" + + rb")" ) re_indirect_def_end = re.compile( - whitespace_optional + br"endobj(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"endobj(?=" + delimiter_or_ws + rb")" ) re_comment = re.compile( - br"(" + whitespace_optional + br"%[^\r\n]*" + newline + br")*" + rb"(" + whitespace_optional + rb"%[^\r\n]*" + newline + rb")*" ) - re_stream_start = re.compile(whitespace_optional + br"stream\r?\n") + re_stream_start = re.compile(whitespace_optional + rb"stream\r?\n") re_stream_end = re.compile( - whitespace_optional + br"endstream(?=" + delimiter_or_ws + br")" + whitespace_optional + rb"endstream(?=" + delimiter_or_ws + rb")" ) @classmethod - def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): + def get_value( + cls, + data: bytes | bytearray | mmap.mmap, + offset: int, + expect_indirect: IndirectReference | None = None, + max_nesting: int = -1, + ) -> tuple[Any, int | None]: if max_nesting == 0: return None, None m = cls.re_comment.match(data, offset) @@ -775,11 +834,16 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): == IndirectReference(int(m.group(1)), int(m.group(2))), "indirect object definition different than expected", ) - object, offset = cls.get_value(data, m.end(), max_nesting=max_nesting - 1) - if offset is None: + object, object_offset = cls.get_value( + data, m.end(), max_nesting=max_nesting - 1 + ) + if object_offset is None: return object, None - m = cls.re_indirect_def_end.match(data, offset) - check_format_condition(m, "indirect object definition end not found") + m = cls.re_indirect_def_end.match(data, object_offset) + check_format_condition( + m is not None, "indirect object definition end not found" + ) + assert m is not None return object, m.end() check_format_condition( not expect_indirect, "indirect object definition not found" @@ -798,47 +862,53 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): m = cls.re_dict_start.match(data, offset) if m: offset = m.end() - result = {} + result: dict[Any, Any] = {} m = cls.re_dict_end.match(data, offset) + current_offset: int | None = offset while not m: - key, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - if offset is None: + assert current_offset is not None + key, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + if current_offset is None: return result, None - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) result[key] = value - if offset is None: + if current_offset is None: return result, None - m = cls.re_dict_end.match(data, offset) - offset = m.end() - m = cls.re_stream_start.match(data, offset) + m = cls.re_dict_end.match(data, current_offset) + current_offset = m.end() + m = cls.re_stream_start.match(data, current_offset) if m: - try: - stream_len = int(result[b"Length"]) - except (TypeError, KeyError, ValueError) as e: - raise PdfFormatError( - "bad or missing Length in stream dict (%r)" - % result.get(b"Length", None) - ) from e + stream_len = result.get(b"Length") + if stream_len is None or not isinstance(stream_len, int): + msg = f"bad or missing Length in stream dict ({stream_len})" + raise PdfFormatError(msg) stream_data = data[m.end() : m.end() + stream_len] m = cls.re_stream_end.match(data, m.end() + stream_len) - check_format_condition(m, "stream end not found") - offset = m.end() - result = PdfStream(PdfDict(result), stream_data) - else: - result = PdfDict(result) - return result, offset + check_format_condition(m is not None, "stream end not found") + assert m is not None + current_offset = m.end() + return PdfStream(PdfDict(result), stream_data), current_offset + return PdfDict(result), current_offset m = cls.re_array_start.match(data, offset) if m: offset = m.end() - result = [] + results = [] m = cls.re_array_end.match(data, offset) + current_offset = offset while not m: - value, offset = cls.get_value(data, offset, max_nesting=max_nesting - 1) - result.append(value) - if offset is None: - return result, None - m = cls.re_array_end.match(data, offset) - return result, m.end() + assert current_offset is not None + value, current_offset = cls.get_value( + data, current_offset, max_nesting=max_nesting - 1 + ) + results.append(value) + if current_offset is None: + return results, None + m = cls.re_array_end.match(data, current_offset) + return results, m.end() m = cls.re_null.match(data, offset) if m: return None, m.end() @@ -862,7 +932,7 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): if m: # filter out whitespace hex_string = bytearray( - [b for b in m.group(1) if b in b"0123456789abcdefABCDEF"] + b for b in m.group(1) if b in b"0123456789abcdefABCDEF" ) if len(hex_string) % 2 == 1: # append a 0 if the length is not even - yes, at the end @@ -872,10 +942,11 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): if m: return cls.get_literal_string(data, m.end()) # return None, offset # fallback (only for debugging) - raise PdfFormatError("unrecognized object: " + repr(data[offset : offset + 32])) + msg = f"unrecognized object: {repr(data[offset : offset + 32])}" + raise PdfFormatError(msg) re_lit_str_token = re.compile( - br"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" + rb"(\\[nrtbf()\\])|(\\[0-9]{1,3})|(\\(\r\n|\r|\n))|(\r\n|\r|\n)|(\()|(\))" ) escaped_chars = { b"n": b"\n", @@ -897,7 +968,9 @@ def get_value(cls, data, offset, expect_indirect=None, max_nesting=-1): } @classmethod - def get_literal_string(cls, data, offset): + def get_literal_string( + cls, data: bytes | bytearray | mmap.mmap, offset: int + ) -> tuple[bytes, int]: nesting_depth = 0 result = bytearray() for m in cls.re_lit_str_token.finditer(data, offset): @@ -919,25 +992,28 @@ def get_literal_string(cls, data, offset): result.extend(b")") nesting_depth -= 1 offset = m.end() - raise PdfFormatError("unfinished literal string") + msg = "unfinished literal string" + raise PdfFormatError(msg) - re_xref_section_start = re.compile(whitespace_optional + br"xref" + newline) + re_xref_section_start = re.compile(whitespace_optional + rb"xref" + newline) re_xref_subsection_start = re.compile( whitespace_optional - + br"([0-9]+)" + + rb"([0-9]+)" + whitespace_mandatory - + br"([0-9]+)" + + rb"([0-9]+)" + whitespace_optional + newline_only ) - re_xref_entry = re.compile(br"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") + re_xref_entry = re.compile(rb"([0-9]{10}) ([0-9]{5}) ([fn])( \r| \n|\r\n)") - def read_xref_table(self, xref_section_offset): + def read_xref_table(self, xref_section_offset: int) -> int: + assert self.buf is not None subsection_found = False m = self.re_xref_section_start.match( self.buf, xref_section_offset + self.start_offset ) - check_format_condition(m, "xref section start not found") + check_format_condition(m is not None, "xref section start not found") + assert m is not None offset = m.end() while True: m = self.re_xref_subsection_start.match(self.buf, offset) @@ -952,26 +1028,25 @@ def read_xref_table(self, xref_section_offset): num_objects = int(m.group(2)) for i in range(first_object, first_object + num_objects): m = self.re_xref_entry.match(self.buf, offset) - check_format_condition(m, "xref entry not found") + check_format_condition(m is not None, "xref entry not found") + assert m is not None offset = m.end() is_free = m.group(3) == b"f" - generation = int(m.group(2)) if not is_free: + generation = int(m.group(2)) new_entry = (int(m.group(1)), generation) - check_format_condition( - i not in self.xref_table or self.xref_table[i] == new_entry, - "xref entry duplicated (and not identical)", - ) - self.xref_table[i] = new_entry + if i not in self.xref_table: + self.xref_table[i] = new_entry return offset - def read_indirect(self, ref, max_nesting=-1): + def read_indirect(self, ref: IndirectReference, max_nesting: int = -1) -> Any: offset, generation = self.xref_table[ref[0]] check_format_condition( generation == ref[1], f"expected to find generation {ref[1]} for object ID {ref[0]} in xref " f"table, instead found generation {generation} at offset {offset}", ) + assert self.buf is not None value = self.get_value( self.buf, offset + self.start_offset, @@ -981,14 +1056,15 @@ def read_indirect(self, ref, max_nesting=-1): self.cached_objects[ref] = value return value - def linearize_page_tree(self, node=None): - if node is None: - node = self.page_tree_root + def linearize_page_tree( + self, node: PdfDict | None = None + ) -> list[IndirectReference]: + page_node = node if node is not None else self.page_tree_root check_format_condition( - node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" + page_node[b"Type"] == b"Pages", "/Type of page tree node is not /Pages" ) pages = [] - for kid in node[b"Kids"]: + for kid in page_node[b"Kids"]: kid_object = self.read_indirect(kid) if kid_object[b"Type"] == b"Page": pages.append(kid) diff --git a/dependencies/WIN/PIL/PixarImagePlugin.py b/dependencies/WIN/PIL/PixarImagePlugin.py index c4860b6..5c465bb 100644 --- a/dependencies/WIN/PIL/PixarImagePlugin.py +++ b/dependencies/WIN/PIL/PixarImagePlugin.py @@ -18,6 +18,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations from . import Image, ImageFile from ._binary import i16le as i16 @@ -26,7 +27,7 @@ # helpers -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"\200\350\000\000" @@ -35,16 +36,17 @@ def _accept(prefix): class PixarImageFile(ImageFile.ImageFile): - format = "PIXAR" format_description = "PIXAR raster image" - def _open(self): - + def _open(self) -> None: # assuming a 4-byte magic label + assert self.fp is not None + s = self.fp.read(4) if not _accept(s): - raise SyntaxError("not a PIXAR file") + msg = "not a PIXAR file" + raise SyntaxError(msg) # read rest of header s = s + self.fp.read(508) @@ -55,11 +57,11 @@ def _open(self): mode = i16(s, 424), i16(s, 426) if mode == (14, 2): - self.mode = "RGB" + self._mode = "RGB" # FIXME: to be continued... # create tile descriptor (assuming "dumped") - self.tile = [("raw", (0, 0) + self.size, 1024, (self.mode, 0, 1))] + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 1024, self.mode)] # diff --git a/dependencies/WIN/PIL/PngImagePlugin.py b/dependencies/WIN/PIL/PngImagePlugin.py index 0f596f1..4b97992 100644 --- a/dependencies/WIN/PIL/PngImagePlugin.py +++ b/dependencies/WIN/PIL/PngImagePlugin.py @@ -30,6 +30,7 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import itertools import logging @@ -37,6 +38,9 @@ import struct import warnings import zlib +from collections.abc import Callable +from enum import IntEnum +from typing import IO, TYPE_CHECKING, Any, NamedTuple, NoReturn, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -45,9 +49,12 @@ from ._binary import o16be as o16 from ._binary import o32be as o32 +if TYPE_CHECKING: + from . import _imaging + logger = logging.getLogger(__name__) -is_cid = re.compile(br"\w\w\w\w").match +is_cid = re.compile(rb"\w\w\w\w").match _MAGIC = b"\211PNG\r\n\032\n" @@ -55,12 +62,12 @@ _MODES = { # supported bits/color combinations, and corresponding modes/rawmodes - # Greyscale + # Grayscale (1, 0): ("1", "1"), (2, 0): ("L", "L;2"), (4, 0): ("L", "L;4"), (8, 0): ("L", "L"), - (16, 0): ("I", "I;16B"), + (16, 0): ("I;16", "I;16B"), # Truecolour (8, 2): ("RGB", "RGB"), (16, 2): ("RGB", "RGB;16B"), @@ -69,7 +76,7 @@ (2, 3): ("P", "P;2"), (4, 3): ("P", "P;4"), (8, 3): ("P", "P"), - # Greyscale with alpha + # Grayscale with alpha (8, 4): ("LA", "LA"), (16, 4): ("RGBA", "LA;16B"), # LA;16B->LA not yet available # Truecolour with alpha @@ -94,47 +101,51 @@ # APNG frame disposal modes -APNG_DISPOSE_OP_NONE = 0 -""" -No disposal is done on this frame before rendering the next frame. -See :ref:`Saving APNG sequences`. -""" -APNG_DISPOSE_OP_BACKGROUND = 1 -""" -This frame’s modified region is cleared to fully transparent black before rendering -the next frame. -See :ref:`Saving APNG sequences`. -""" -APNG_DISPOSE_OP_PREVIOUS = 2 -""" -This frame’s modified region is reverted to the previous frame’s contents before -rendering the next frame. -See :ref:`Saving APNG sequences`. -""" +class Disposal(IntEnum): + OP_NONE = 0 + """ + No disposal is done on this frame before rendering the next frame. + See :ref:`Saving APNG sequences`. + """ + OP_BACKGROUND = 1 + """ + This frame’s modified region is cleared to fully transparent black before rendering + the next frame. + See :ref:`Saving APNG sequences`. + """ + OP_PREVIOUS = 2 + """ + This frame’s modified region is reverted to the previous frame’s contents before + rendering the next frame. + See :ref:`Saving APNG sequences`. + """ + # APNG frame blend modes -APNG_BLEND_OP_SOURCE = 0 -""" -All color components of this frame, including alpha, overwrite the previous output -image contents. -See :ref:`Saving APNG sequences`. -""" -APNG_BLEND_OP_OVER = 1 -""" -This frame should be alpha composited with the previous output image contents. -See :ref:`Saving APNG sequences`. -""" +class Blend(IntEnum): + OP_SOURCE = 0 + """ + All color components of this frame, including alpha, overwrite the previous output + image contents. + See :ref:`Saving APNG sequences`. + """ + OP_OVER = 1 + """ + This frame should be alpha composited with the previous output image contents. + See :ref:`Saving APNG sequences`. + """ -def _safe_zlib_decompress(s): +def _safe_zlib_decompress(s: bytes) -> bytes: dobj = zlib.decompressobj() plaintext = dobj.decompress(s, MAX_TEXT_CHUNK) if dobj.unconsumed_tail: - raise ValueError("Decompressed Data Too Large") + msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK" + raise ValueError(msg) return plaintext -def _crc32(data, seed=0): +def _crc32(data: bytes, seed: int = 0) -> int: return zlib.crc32(data, seed) & 0xFFFFFFFF @@ -143,15 +154,15 @@ def _crc32(data, seed=0): class ChunkStream: - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: + self.fp: IO[bytes] | None = fp + self.queue: list[tuple[bytes, int, int]] | None = [] - self.fp = fp - self.queue = [] - - def read(self): + def read(self) -> tuple[bytes, int, int]: """Fetch a new chunk. Returns header information.""" cid = None + assert self.fp is not None if self.queue: cid, pos, length = self.queue.pop() self.fp.seek(pos) @@ -163,30 +174,31 @@ def read(self): if not is_cid(cid): if not ImageFile.LOAD_TRUNCATED_IMAGES: - raise SyntaxError(f"broken PNG file (chunk {repr(cid)})") + msg = f"broken PNG file (chunk {repr(cid)})" + raise SyntaxError(msg) return cid, pos, length - def __enter__(self): + def __enter__(self) -> ChunkStream: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() - def close(self): - self.queue = self.crc = self.fp = None - - def push(self, cid, pos, length): + def close(self) -> None: + self.queue = self.fp = None + def push(self, cid: bytes, pos: int, length: int) -> None: + assert self.queue is not None self.queue.append((cid, pos, length)) - def call(self, cid, pos, length): + def call(self, cid: bytes, pos: int, length: int) -> bytes: """Call the appropriate chunk handler""" logger.debug("STREAM %r %s %s", cid, pos, length) - return getattr(self, "chunk_" + cid.decode("ascii"))(pos, length) + return getattr(self, f"chunk_{cid.decode('ascii')}")(pos, length) - def crc(self, cid, data): + def crc(self, cid: bytes, data: bytes) -> None: """Read and verify checksum""" # Skip CRC checks for ancillary chunks if allowed to load truncated @@ -196,35 +208,36 @@ def crc(self, cid, data): self.crc_skip(cid, data) return + assert self.fp is not None try: crc1 = _crc32(data, _crc32(cid)) crc2 = i32(self.fp.read(4)) if crc1 != crc2: - raise SyntaxError( - f"broken PNG file (bad header checksum in {repr(cid)})" - ) + msg = f"broken PNG file (bad header checksum in {repr(cid)})" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError( - f"broken PNG file (incomplete checksum in {repr(cid)})" - ) from e + msg = f"broken PNG file (incomplete checksum in {repr(cid)})" + raise SyntaxError(msg) from e - def crc_skip(self, cid, data): - """Read checksum. Used if the C module is not present""" + def crc_skip(self, cid: bytes, data: bytes) -> None: + """Read checksum""" + assert self.fp is not None self.fp.read(4) - def verify(self, endchunk=b"IEND"): - + def verify(self, endchunk: bytes = b"IEND") -> list[bytes]: # Simple approach; just calculate checksum for all remaining # blocks. Must be called directly after open. cids = [] + assert self.fp is not None while True: try: cid, pos, length = self.read() except struct.error as e: - raise OSError("truncated PNG file") from e + msg = "truncated PNG file" + raise OSError(msg) from e if cid == endchunk: break @@ -241,8 +254,13 @@ class iTXt(str): """ + lang: str | bytes | None + tkey: str | bytes | None + @staticmethod - def __new__(cls, text, lang=None, tkey=None): + def __new__( + cls, text: str, lang: str | None = None, tkey: str | None = None + ) -> iTXt: """ :param cls: the class to use when creating the instance :param text: value for this key @@ -262,10 +280,10 @@ class PngInfo: """ - def __init__(self): - self.chunks = [] + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] - def add(self, cid, data, after_idat=False): + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: """Appends an arbitrary chunk. Use with caution. :param cid: a byte string, 4 bytes long. @@ -275,12 +293,16 @@ def add(self, cid, data, after_idat=False): """ - chunk = [cid, data] - if after_idat: - chunk.append(True) - self.chunks.append(tuple(chunk)) + self.chunks.append((cid, data, after_idat)) - def add_itxt(self, key, value, lang="", tkey="", zip=False): + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: """Appends an iTXt chunk. :param key: latin-1 encodable text key name @@ -308,7 +330,9 @@ def add_itxt(self, key, value, lang="", tkey="", zip=False): else: self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) - def add_text(self, key, value, zip=False): + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: """Appends a text chunk. :param key: latin-1 encodable text key name @@ -318,7 +342,13 @@ def add_text(self, key, value, zip=False): """ if isinstance(value, iTXt): - return self.add_itxt(key, value, value.lang, value.tkey, zip=zip) + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) # The tEXt chunk stores latin-1 text if not isinstance(value, bytes): @@ -340,47 +370,54 @@ def add_text(self, key, value, zip=False): # PNG image stream (IHDR/IEND) +class _RewindState(NamedTuple): + info: dict[str | tuple[int, int], Any] + tile: list[ImageFile._Tile] + seq_num: int | None + + class PngStream(ChunkStream): - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: super().__init__(fp) # local copies of Image attributes - self.im_info = {} - self.im_text = {} + self.im_info: dict[str | tuple[int, int], Any] = {} + self.im_text: dict[str, str | iTXt] = {} self.im_size = (0, 0) - self.im_mode = None - self.im_tile = None - self.im_palette = None - self.im_custom_mimetype = None - self.im_n_frames = None - self._seq_num = None - self.rewind_state = None + self.im_mode = "" + self.im_tile: list[ImageFile._Tile] = [] + self.im_palette: tuple[str, bytes] | None = None + self.im_custom_mimetype: str | None = None + self.im_n_frames: int | None = None + self._seq_num: int | None = None + self.rewind_state = _RewindState({}, [], None) self.text_memory = 0 - def check_text_memory(self, chunklen): + def check_text_memory(self, chunklen: int) -> None: self.text_memory += chunklen if self.text_memory > MAX_TEXT_MEMORY: - raise ValueError( + msg = ( "Too much memory used in text chunks: " f"{self.text_memory}>MAX_TEXT_MEMORY" ) + raise ValueError(msg) - def save_rewind(self): - self.rewind_state = { - "info": self.im_info.copy(), - "tile": self.im_tile, - "seq_num": self._seq_num, - } - - def rewind(self): - self.im_info = self.rewind_state["info"] - self.im_tile = self.rewind_state["tile"] - self._seq_num = self.rewind_state["seq_num"] + def save_rewind(self) -> None: + self.rewind_state = _RewindState( + self.im_info.copy(), + self.im_tile, + self._seq_num, + ) - def chunk_iCCP(self, pos, length): + def rewind(self) -> None: + self.im_info = self.rewind_state.info.copy() + self.im_tile = self.rewind_state.tile + self._seq_num = self.rewind_state.seq_num + def chunk_iCCP(self, pos: int, length: int) -> bytes: # ICC profile + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) # according to PNG spec, the iCCP chunk contains: # Profile name 1-79 bytes (character string) @@ -389,10 +426,11 @@ def chunk_iCCP(self, pos, length): # Compressed profile n bytes (zlib with deflate compression) i = s.find(b"\0") logger.debug("iCCP profile name %r", s[:i]) - logger.debug("Compression method %s", s[i]) - comp_method = s[i] + comp_method = s[i + 1] + logger.debug("Compression method %s", comp_method) if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in iCCP chunk") + msg = f"Unknown compression method {comp_method} in iCCP chunk" + raise SyntaxError(msg) try: icc_profile = _safe_zlib_decompress(s[i + 2 :]) except ValueError: @@ -405,10 +443,15 @@ def chunk_iCCP(self, pos, length): self.im_info["icc_profile"] = icc_profile return s - def chunk_IHDR(self, pos, length): - + def chunk_IHDR(self, pos: int, length: int) -> bytes: # image header + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 13: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated IHDR chunk" + raise ValueError(msg) self.im_size = i32(s, 0), i32(s, 4) try: self.im_mode, self.im_rawmode = _MODES[(s[8], s[9])] @@ -417,38 +460,38 @@ def chunk_IHDR(self, pos, length): if s[12]: self.im_info["interlace"] = 1 if s[11]: - raise SyntaxError("unknown filter category") + msg = "unknown filter category" + raise SyntaxError(msg) return s - def chunk_IDAT(self, pos, length): - + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: - tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", self.im_info["bbox"], pos, self.im_rawmode)] else: if self.im_n_frames is not None: self.im_info["default_image"] = True - tile = [("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] + tile = [ImageFile._Tile("zip", (0, 0) + self.im_size, pos, self.im_rawmode)] self.im_tile = tile self.im_idat = length - raise EOFError + msg = "image data found" + raise EOFError(msg) - def chunk_IEND(self, pos, length): - - # end of PNG image - raise EOFError - - def chunk_PLTE(self, pos, length): + def chunk_IEND(self, pos: int, length: int) -> NoReturn: + msg = "end of PNG image" + raise EOFError(msg) + def chunk_PLTE(self, pos: int, length: int) -> bytes: # palette + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": self.im_palette = "RGB", s return s - def chunk_tRNS(self, pos, length): - + def chunk_tRNS(self, pos: int, length: int) -> bytes: # transparency + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) if self.im_mode == "P": if _simple_palette.match(s): @@ -461,42 +504,55 @@ def chunk_tRNS(self, pos, length): # otherwise, we have a byte string with one alpha value # for each palette entry self.im_info["transparency"] = s - elif self.im_mode in ("1", "L", "I"): + elif self.im_mode in ("1", "L", "I;16"): self.im_info["transparency"] = i16(s) elif self.im_mode == "RGB": self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) return s - def chunk_gAMA(self, pos, length): + def chunk_gAMA(self, pos: int, length: int) -> bytes: # gamma setting + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["gamma"] = i32(s) / 100000.0 return s - def chunk_cHRM(self, pos, length): + def chunk_cHRM(self, pos: int, length: int) -> bytes: # chromaticity, 8 unsigned ints, actual value is scaled by 100,000 # WP x,y, Red x,y, Green x,y Blue x,y + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) - raw_vals = struct.unpack(">%dI" % (len(s) // 4), s) + raw_vals = struct.unpack(f">{len(s) // 4}I", s) self.im_info["chromaticity"] = tuple(elt / 100000.0 for elt in raw_vals) return s - def chunk_sRGB(self, pos, length): + def chunk_sRGB(self, pos: int, length: int) -> bytes: # srgb rendering intent, 1 byte # 0 perceptual # 1 relative colorimetric # 2 saturation # 3 absolute colorimetric + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 1: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated sRGB chunk" + raise ValueError(msg) self.im_info["srgb"] = s[0] return s - def chunk_pHYs(self, pos, length): - + def chunk_pHYs(self, pos: int, length: int) -> bytes: # pixels per unit + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 9: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "Truncated pHYs chunk" + raise ValueError(msg) px, py = i32(s, 0), i32(s, 4) unit = s[8] if unit == 1: # meter @@ -506,9 +562,9 @@ def chunk_pHYs(self, pos, length): self.im_info["aspect"] = px, py return s - def chunk_tEXt(self, pos, length): - + def chunk_tEXt(self, pos: int, length: int) -> bytes: # text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -517,18 +573,18 @@ def chunk_tEXt(self, pos, length): k = s v = b"" if k: - k = k.decode("latin-1", "strict") + k_str = k.decode("latin-1", "strict") v_str = v.decode("latin-1", "replace") - self.im_info[k] = v if k == "exif" else v_str - self.im_text[k] = v_str + self.im_info[k_str] = v if k == b"exif" else v_str + self.im_text[k_str] = v_str self.check_text_memory(len(v_str)) return s - def chunk_zTXt(self, pos, length): - + def chunk_zTXt(self, pos: int, length: int) -> bytes: # compressed text + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) try: k, v = s.split(b"\0", 1) @@ -540,7 +596,8 @@ def chunk_zTXt(self, pos, length): else: comp_method = 0 if comp_method != 0: - raise SyntaxError(f"Unknown compression method {comp_method} in zTXt chunk") + msg = f"Unknown compression method {comp_method} in zTXt chunk" + raise SyntaxError(msg) try: v = _safe_zlib_decompress(v[1:]) except ValueError: @@ -552,17 +609,17 @@ def chunk_zTXt(self, pos, length): v = b"" if k: - k = k.decode("latin-1", "strict") - v = v.decode("latin-1", "replace") + k_str = k.decode("latin-1", "strict") + v_str = v.decode("latin-1", "replace") - self.im_info[k] = self.im_text[k] = v - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = v_str + self.check_text_memory(len(v_str)) return s - def chunk_iTXt(self, pos, length): - + def chunk_iTXt(self, pos: int, length: int) -> bytes: # international text + assert self.fp is not None r = s = ImageFile._safe_read(self.fp, length) try: k, r = r.split(b"\0", 1) @@ -588,27 +645,36 @@ def chunk_iTXt(self, pos, length): return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: - k = k.decode("latin-1", "strict") - lang = lang.decode("utf-8", "strict") - tk = tk.decode("utf-8", "strict") - v = v.decode("utf-8", "strict") + k_str = k.decode("latin-1", "strict") + lang_str = lang.decode("utf-8", "strict") + tk_str = tk.decode("utf-8", "strict") + v_str = v.decode("utf-8", "strict") except UnicodeError: return s - self.im_info[k] = self.im_text[k] = iTXt(v, lang, tk) - self.check_text_memory(len(v)) + self.im_info[k_str] = self.im_text[k_str] = iTXt(v_str, lang_str, tk_str) + self.check_text_memory(len(v_str)) return s - def chunk_eXIf(self, pos, length): + def chunk_eXIf(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) self.im_info["exif"] = b"Exif\x00\x00" + s return s # APNG chunks - def chunk_acTL(self, pos, length): + def chunk_acTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 8: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated acTL chunk" + raise ValueError(msg) if self.im_n_frames is not None: self.im_n_frames = None warnings.warn("Invalid APNG, will use default PNG image if possible") @@ -622,19 +688,27 @@ def chunk_acTL(self, pos, length): self.im_custom_mimetype = "image/apng" return s - def chunk_fcTL(self, pos, length): + def chunk_fcTL(self, pos: int, length: int) -> bytes: + assert self.fp is not None s = ImageFile._safe_read(self.fp, length) + if length < 26: + if ImageFile.LOAD_TRUNCATED_IMAGES: + return s + msg = "APNG contains truncated fcTL chunk" + raise ValueError(msg) seq = i32(s) if (self._seq_num is None and seq != 0) or ( self._seq_num is not None and self._seq_num != seq - 1 ): - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq width, height = i32(s, 4), i32(s, 8) px, py = i32(s, 12), i32(s, 16) im_w, im_h = self.im_size if px + width > im_w or py + height > im_h: - raise SyntaxError("APNG contains invalid frames") + msg = "APNG contains invalid frames" + raise SyntaxError(msg) self.im_info["bbox"] = (px, py, px + width, py + height) delay_num, delay_den = i16(s, 20), i16(s, 22) if delay_den == 0: @@ -644,11 +718,19 @@ def chunk_fcTL(self, pos, length): self.im_info["blend"] = s[25] return s - def chunk_fdAT(self, pos, length): + def chunk_fdAT(self, pos: int, length: int) -> bytes: + assert self.fp is not None + if length < 4: + if ImageFile.LOAD_TRUNCATED_IMAGES: + s = ImageFile._safe_read(self.fp, length) + return s + msg = "APNG contains truncated fDAT chunk" + raise ValueError(msg) s = ImageFile._safe_read(self.fp, 4) seq = i32(s) if self._seq_num != seq - 1: - raise SyntaxError("APNG contains frame sequence errors") + msg = "APNG contains frame sequence errors" + raise SyntaxError(msg) self._seq_num = seq return self.chunk_IDAT(pos + 4, length - 4) @@ -657,7 +739,7 @@ def chunk_fdAT(self, pos, length): # PNG reader -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:8] == _MAGIC @@ -666,25 +748,23 @@ def _accept(prefix): class PngImageFile(ImageFile.ImageFile): - format = "PNG" format_description = "Portable network graphics" - def _open(self): - + def _open(self) -> None: if not _accept(self.fp.read(8)): - raise SyntaxError("not a PNG file") - self.__fp = self.fp + msg = "not a PNG file" + raise SyntaxError(msg) + self._fp = self.fp self.__frame = 0 # # Parse headers up to the first IDAT or fDAT chunk - self.private_chunks = [] - self.png = PngStream(self.fp) + self.private_chunks: list[tuple[bytes, bytes] | tuple[bytes, bytes, bool]] = [] + self.png: PngStream | None = PngStream(self.fp) while True: - # # get next chunk @@ -709,10 +789,10 @@ def _open(self): # difficult to break if things go wrong in the decoder... # (believe me, I've tried ;-) - self.mode = self.png.im_mode + self._mode = self.png.im_mode self._size = self.png.im_size self.info = self.png.im_info - self._text = None + self._text: dict[str, str | iTXt] | None = None self.tile = self.png.im_tile self.custom_mimetype = self.png.im_custom_mimetype self.n_frames = self.png.im_n_frames or 1 @@ -731,7 +811,7 @@ def _open(self): self._close_exclusive_fp_after_loading = False self.png.save_rewind() self.__rewind_idat = self.__prepare_idat - self.__rewind = self.__fp.tell() + self.__rewind = self._fp.tell() if self.default_image: # IDAT chunk contains default image and not first animation frame self.n_frames += 1 @@ -739,7 +819,7 @@ def _open(self): self.is_animated = self.n_frames > 1 @property - def text(self): + def text(self) -> dict[str, str | iTXt]: # experimental if self._text is None: # iTxt, tEXt and zTXt chunks may appear at the end of the file @@ -751,17 +831,20 @@ def text(self): self.load() if self.is_animated: self.seek(frame) + assert self._text is not None return self._text - def verify(self): + def verify(self) -> None: """Verify PNG file""" if self.fp is None: - raise RuntimeError("verify must be called directly after open") + msg = "verify must be called directly after open" + raise RuntimeError(msg) # back up to beginning of IDAT block self.fp.seek(self.tile[0][2] - 8) + assert self.png is not None self.png.verify() self.png.close() @@ -769,7 +852,7 @@ def verify(self): self.fp.close() self.fp = None - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return if frame < self.__frame: @@ -781,30 +864,34 @@ def seek(self, frame): self._seek(f) except EOFError as e: self.seek(last_frame) - raise EOFError("no more images in APNG file") from e + msg = "no more images in APNG file" + raise EOFError(msg) from e + + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None - def _seek(self, frame, rewind=False): + self.dispose: _imaging.ImagingCore | None + dispose_extent = None if frame == 0: if rewind: - self.__fp.seek(self.__rewind) + self._fp.seek(self.__rewind) self.png.rewind() self.__prepare_idat = self.__rewind_idat - self.im = None - if self.pyaccess: - self.pyaccess = None + self._im = None self.info = self.png.im_info self.tile = self.png.im_tile - self.fp = self.__fp + self.fp = self._fp self._prev_im = None self.dispose = None self.default_image = self.info.get("default_image", False) self.dispose_op = self.info.get("disposal") self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") + dispose_extent = self.info.get("bbox") self.__frame = 0 else: if frame != self.__frame + 1: - raise ValueError(f"cannot seek to frame {frame}") + msg = f"cannot seek to frame {frame}" + raise ValueError(msg) # ensure previous frame was loaded self.load() @@ -813,7 +900,7 @@ def _seek(self, frame, rewind=False): self.im.paste(self.dispose, self.dispose_extent) self._prev_im = self.im.copy() - self.fp = self.__fp + self.fp = self._fp # advance to the next frame if self.__prepare_idat: @@ -829,11 +916,13 @@ def _seek(self, frame, rewind=False): break if cid == b"IEND": - raise EOFError("No more images in APNG file") + msg = "No more images in APNG file" + raise EOFError(msg) if cid == b"fcTL": if frame_start: # there must be at least one fdAT chunk between fcTL chunks - raise SyntaxError("APNG missing frame data") + msg = "APNG missing frame data" + raise SyntaxError(msg) frame_start = True try: @@ -855,28 +944,31 @@ def _seek(self, frame, rewind=False): self.tile = self.png.im_tile self.dispose_op = self.info.get("disposal") self.blend_op = self.info.get("blend") - self.dispose_extent = self.info.get("bbox") + dispose_extent = self.info.get("bbox") if not self.tile: - raise EOFError + msg = "image not found in APNG frame" + raise EOFError(msg) + if dispose_extent: + self.dispose_extent: tuple[float, float, float, float] = dispose_extent # setup frame disposal (actual disposal done when needed in the next _seek()) - if self._prev_im is None and self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: - self.dispose_op = APNG_DISPOSE_OP_BACKGROUND - - if self.dispose_op == APNG_DISPOSE_OP_PREVIOUS: - self.dispose = self._prev_im.copy() - self.dispose = self._crop(self.dispose, self.dispose_extent) - elif self.dispose_op == APNG_DISPOSE_OP_BACKGROUND: + if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: + self.dispose_op = Disposal.OP_BACKGROUND + + self.dispose = None + if self.dispose_op == Disposal.OP_PREVIOUS: + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) + elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) - else: - self.dispose = None - def tell(self): + def tell(self) -> int: return self.__frame - def load_prepare(self): + def load_prepare(self) -> None: """internal: prepare to read PNG file""" if self.info.get("interlace"): @@ -885,9 +977,10 @@ def load_prepare(self): self.__idat = self.__prepare_idat # used by load_read() ImageFile.ImageFile.load_prepare(self) - def load_read(self, read_bytes): + def load_read(self, read_bytes: int) -> bytes: """internal: read more image data""" + assert self.png is not None while self.__idat == 0: # end of chunk, skip forward to next one @@ -918,8 +1011,9 @@ def load_read(self, read_bytes): return self.fp.read(read_bytes) - def load_end(self): + def load_end(self) -> None: """internal: finished reading image data""" + assert self.png is not None if self.__idat != 0: self.fp.read(self.__idat) while True: @@ -945,7 +1039,13 @@ def load_end(self): except EOFError: if cid == b"fdAT": length -= 4 - ImageFile._safe_read(self.fp, length) + try: + ImageFile._safe_read(self.fp, length) + except OSError as e: + if ImageFile.LOAD_TRUNCATED_IMAGES: + break + else: + raise e except AttributeError: logger.debug("%r %s %s (unknown)", cid, pos, length) s = ImageFile._safe_read(self.fp, length) @@ -956,134 +1056,133 @@ def load_end(self): self.png.close() self.png = None else: - if self._prev_im and self.blend_op == APNG_BLEND_OP_OVER: + if self._prev_im and self.blend_op == Blend.OP_OVER: updated = self._crop(self.im, self.dispose_extent) - self._prev_im.paste( - updated, self.dispose_extent, updated.convert("RGBA") - ) + if self.im.mode == "RGB" and "transparency" in self.info: + mask = updated.convert_transparent( + "RGBA", self.info["transparency"] + ) + else: + if self.im.mode == "P" and "transparency" in self.info: + t = self.info["transparency"] + if isinstance(t, bytes): + updated.putpalettealphas(t) + elif isinstance(t, int): + updated.putpalettealpha(t) + mask = updated.convert("RGBA") + self._prev_im.paste(updated, self.dispose_extent, mask) self.im = self._prev_im - if self.pyaccess: - self.pyaccess = None - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: if "exif" not in self.info: self.load() if "exif" not in self.info and "Raw profile type exif" not in self.info: return None return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() return super().getexif() - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - :returns: XMP tags in a dictionary. - """ - return ( - self._getxmp(self.info["XML:com.adobe.xmp"]) - if "XML:com.adobe.xmp" in self.info - else {} - ) - - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # -------------------------------------------------------------------- # PNG writer _OUTMODES = { - # supported PIL modes, and corresponding rawmodes/bits/color combinations - "1": ("1", b"\x01\x00"), - "L;1": ("L;1", b"\x01\x00"), - "L;2": ("L;2", b"\x02\x00"), - "L;4": ("L;4", b"\x04\x00"), - "L": ("L", b"\x08\x00"), - "LA": ("LA", b"\x08\x04"), - "I": ("I;16B", b"\x10\x00"), - "I;16": ("I;16B", b"\x10\x00"), - "P;1": ("P;1", b"\x01\x03"), - "P;2": ("P;2", b"\x02\x03"), - "P;4": ("P;4", b"\x04\x03"), - "P": ("P", b"\x08\x03"), - "RGB": ("RGB", b"\x08\x02"), - "RGBA": ("RGBA", b"\x08\x06"), + # supported PIL modes, and corresponding rawmode, bit depth and color type + "1": ("1", b"\x01", b"\x00"), + "L;1": ("L;1", b"\x01", b"\x00"), + "L;2": ("L;2", b"\x02", b"\x00"), + "L;4": ("L;4", b"\x04", b"\x00"), + "L": ("L", b"\x08", b"\x00"), + "LA": ("LA", b"\x08", b"\x04"), + "I": ("I;16B", b"\x10", b"\x00"), + "I;16": ("I;16B", b"\x10", b"\x00"), + "I;16B": ("I;16B", b"\x10", b"\x00"), + "P;1": ("P;1", b"\x01", b"\x03"), + "P;2": ("P;2", b"\x02", b"\x03"), + "P;4": ("P;4", b"\x04", b"\x03"), + "P": ("P", b"\x08", b"\x03"), + "RGB": ("RGB", b"\x08", b"\x02"), + "RGBA": ("RGBA", b"\x08", b"\x06"), } -def putchunk(fp, cid, *data): +def putchunk(fp: IO[bytes], cid: bytes, *data: bytes) -> None: """Write a PNG chunk (including CRC field)""" - data = b"".join(data) + byte_data = b"".join(data) - fp.write(o32(len(data)) + cid) - fp.write(data) - crc = _crc32(data, _crc32(cid)) + fp.write(o32(len(byte_data)) + cid) + fp.write(byte_data) + crc = _crc32(byte_data, _crc32(cid)) fp.write(o32(crc)) class _idat: # wrap output from the encoder in IDAT chunks - def __init__(self, fp, chunk): + def __init__(self, fp: IO[bytes], chunk: Callable[..., None]) -> None: self.fp = fp self.chunk = chunk - def write(self, data): + def write(self, data: bytes) -> None: self.chunk(self.fp, b"IDAT", data) class _fdat: # wrap encoder output in fdAT chunks - def __init__(self, fp, chunk, seq_num): + def __init__(self, fp: IO[bytes], chunk: Callable[..., None], seq_num: int) -> None: self.fp = fp self.chunk = chunk self.seq_num = seq_num - def write(self, data): + def write(self, data: bytes) -> None: self.chunk(self.fp, b"fdAT", o32(self.seq_num), data) self.seq_num += 1 -def _write_multiple_frames(im, fp, chunk, rawmode): - default_image = im.encoderinfo.get("default_image", im.info.get("default_image")) - duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) +class _Frame(NamedTuple): + im: Image.Image + bbox: tuple[int, int, int, int] | None + encoderinfo: dict[str, Any] + + +def _write_multiple_frames( + im: Image.Image, + fp: IO[bytes], + chunk: Callable[..., None], + mode: str, + rawmode: str, + default_image: Image.Image | None, + append_images: list[Image.Image], +) -> Image.Image | None: + duration = im.encoderinfo.get("duration") loop = im.encoderinfo.get("loop", im.info.get("loop", 0)) - disposal = im.encoderinfo.get( - "disposal", im.info.get("disposal", APNG_DISPOSE_OP_NONE) - ) - blend = im.encoderinfo.get("blend", im.info.get("blend", APNG_BLEND_OP_SOURCE)) + disposal = im.encoderinfo.get("disposal", im.info.get("disposal", Disposal.OP_NONE)) + blend = im.encoderinfo.get("blend", im.info.get("blend", Blend.OP_SOURCE)) if default_image: - chain = itertools.chain(im.encoderinfo.get("append_images", [])) + chain = itertools.chain(append_images) else: - chain = itertools.chain([im], im.encoderinfo.get("append_images", [])) + chain = itertools.chain([im], append_images) - im_frames = [] + im_frames: list[_Frame] = [] frame_count = 0 for im_seq in chain: for im_frame in ImageSequence.Iterator(im_seq): - im_frame = im_frame.copy() - if im_frame.mode != im.mode: - if im.mode == "P": - im_frame = im_frame.convert(im.mode, palette=im.palette) - else: - im_frame = im_frame.convert(im.mode) + if im_frame.mode == mode: + im_frame = im_frame.copy() + else: + im_frame = im_frame.convert(mode) encoderinfo = im.encoderinfo.copy() if isinstance(duration, (list, tuple)): encoderinfo["duration"] = duration[frame_count] + elif duration is None and "duration" in im_frame.info: + encoderinfo["duration"] = im_frame.info["duration"] if isinstance(disposal, (list, tuple)): encoderinfo["disposal"] = disposal[frame_count] if isinstance(blend, (list, tuple)): @@ -1092,39 +1191,42 @@ def _write_multiple_frames(im, fp, chunk, rawmode): if im_frames: previous = im_frames[-1] - prev_disposal = previous["encoderinfo"].get("disposal") - prev_blend = previous["encoderinfo"].get("blend") - if prev_disposal == APNG_DISPOSE_OP_PREVIOUS and len(im_frames) < 2: - prev_disposal = APNG_DISPOSE_OP_BACKGROUND + prev_disposal = previous.encoderinfo.get("disposal") + prev_blend = previous.encoderinfo.get("blend") + if prev_disposal == Disposal.OP_PREVIOUS and len(im_frames) < 2: + prev_disposal = Disposal.OP_BACKGROUND - if prev_disposal == APNG_DISPOSE_OP_BACKGROUND: - base_im = previous["im"] + if prev_disposal == Disposal.OP_BACKGROUND: + base_im = previous.im.copy() dispose = Image.core.fill("RGBA", im.size, (0, 0, 0, 0)) - bbox = previous["bbox"] + bbox = previous.bbox if bbox: dispose = dispose.crop(bbox) else: bbox = (0, 0) + im.size base_im.paste(dispose, bbox) - elif prev_disposal == APNG_DISPOSE_OP_PREVIOUS: - base_im = im_frames[-2]["im"] + elif prev_disposal == Disposal.OP_PREVIOUS: + base_im = im_frames[-2].im else: - base_im = previous["im"] + base_im = previous.im delta = ImageChops.subtract_modulo( - im_frame.convert("RGB"), base_im.convert("RGB") + im_frame.convert("RGBA"), base_im.convert("RGBA") ) - bbox = delta.getbbox() + bbox = delta.getbbox(alpha_only=False) if ( not bbox and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") + and "duration" in encoderinfo ): - if isinstance(duration, (list, tuple)): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + previous.encoderinfo["duration"] += encoderinfo["duration"] continue else: bbox = None - im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) + im_frames.append(_Frame(im_frame, bbox, encoderinfo)) + + if len(im_frames) == 1 and not default_image: + return im_frames[0].im # animation control chunk( @@ -1136,19 +1238,25 @@ def _write_multiple_frames(im, fp, chunk, rawmode): # default image IDAT (if it exists) if default_image: - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + if im.mode != mode: + im = im.convert(mode) + ImageFile._save( + im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im.size, 0, rawmode)], + ) seq_num = 0 for frame, frame_data in enumerate(im_frames): - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: bbox = (0, 0) + im_frame.size else: - bbox = frame_data["bbox"] + bbox = frame_data.bbox im_frame = im_frame.crop(bbox) size = im_frame.size - encoderinfo = frame_data["encoderinfo"] - frame_duration = int(round(encoderinfo.get("duration", duration))) + encoderinfo = frame_data.encoderinfo + frame_duration = int(round(encoderinfo.get("duration", 0))) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control @@ -1171,30 +1279,56 @@ def _write_multiple_frames(im, fp, chunk, rawmode): # first frame must be in IDAT chunks for backwards compatibility ImageFile._save( im_frame, - _idat(fp, chunk), - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) else: fdat_chunks = _fdat(fp, chunk, seq_num) ImageFile._save( im_frame, - fdat_chunks, - [("zip", (0, 0) + im_frame.size, 0, rawmode)], + cast(IO[bytes], fdat_chunks), + [ImageFile._Tile("zip", (0, 0) + im_frame.size, 0, rawmode)], ) seq_num = fdat_chunks.seq_num + return None -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) -def _save(im, fp, filename, chunk=putchunk, save_all=False): +def _save( + im: Image.Image, + fp: IO[bytes], + filename: str | bytes, + chunk: Callable[..., None] = putchunk, + save_all: bool = False, +) -> None: # save an image to disk (called by the save method) - mode = im.mode + if save_all: + default_image = im.encoderinfo.get( + "default_image", im.info.get("default_image") + ) + modes = set() + sizes = set() + append_images = im.encoderinfo.get("append_images", []) + for im_seq in itertools.chain([im], append_images): + for im_frame in ImageSequence.Iterator(im_seq): + modes.add(im_frame.mode) + sizes.add(im_frame.size) + for mode in ("RGBA", "RGB", "P"): + if mode in modes: + break + else: + mode = modes.pop() + size = tuple(max(frame_size[i] for frame_size in sizes) for i in range(2)) + else: + size = im.size + mode = im.mode + outmode = mode if mode == "P": - # # attempt to minimize storage requirements for palette images if "bits" in im.encoderinfo: @@ -1214,7 +1348,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): bits = 2 else: bits = 4 - mode = f"{mode};{bits}" + outmode += f";{bits}" # encoder options im.encoderconfig = ( @@ -1226,9 +1360,10 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # get the corresponding PNG mode try: - rawmode, mode = _OUTMODES[mode] + rawmode, bit_depth, color_type = _OUTMODES[outmode] except KeyError as e: - raise OSError(f"cannot write mode {mode} as PNG") from e + msg = f"cannot write mode {mode} as PNG" + raise OSError(msg) from e # # write minimal PNG file @@ -1238,9 +1373,10 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk( fp, b"IHDR", - o32(im.size[0]), # 0: size - o32(im.size[1]), - mode, # 8: depth/type + o32(size[0]), # 0: size + o32(size[1]), + bit_depth, + color_type, b"\0", # 10: compression b"\0", # 11: filter category b"\0", # 12: interlace flag @@ -1276,7 +1412,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, cid, data) elif cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if not after_idat: chunk(fp, cid, data) @@ -1299,7 +1435,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): transparency = max(0, min(255, transparency)) alpha = b"\xFF" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) - elif im.mode in ("1", "L", "I"): + elif im.mode in ("1", "L", "I", "I;16"): transparency = max(0, min(65535, transparency)) chunk(fp, b"tRNS", o16(transparency)) elif im.mode == "RGB": @@ -1309,7 +1445,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): if "transparency" in im.encoderinfo: # don't bother with transparency if it's an RGBA # and it's in the info dict. It's probably just stale. - raise OSError("cannot use transparency for this mode") + msg = "cannot use transparency for this mode" + raise OSError(msg) else: if im.mode == "P" and im.im.getpalettemode() == "RGBA": alpha = im.im.getpalette("RGBA", "A") @@ -1334,7 +1471,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunks.remove(cid) chunk(fp, cid, data) - exif = im.encoderinfo.get("exif", im.info.get("exif")) + exif = im.encoderinfo.get("exif") if exif: if isinstance(exif, Image.Exif): exif = exif.tobytes(8) @@ -1342,17 +1479,24 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): exif = exif[6:] chunk(fp, b"eXIf", exif) + single_im: Image.Image | None = im if save_all: - _write_multiple_frames(im, fp, chunk, rawmode) - else: - ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)]) + single_im = _write_multiple_frames( + im, fp, chunk, mode, rawmode, default_image, append_images + ) + if single_im: + ImageFile._save( + single_im, + cast(IO[bytes], _idat(fp, chunk)), + [ImageFile._Tile("zip", (0, 0) + single_im.size, 0, rawmode)], + ) if info: for info_chunk in info.chunks: cid, data = info_chunk[:2] if cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if after_idat: chunk(fp, cid, data) @@ -1366,32 +1510,26 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): # PNG chunk converter -def getchunks(im, **params): +def getchunks(im: Image.Image, **params: Any) -> list[tuple[bytes, bytes, bytes]]: """Return a list of PNG chunks representing this image.""" + from io import BytesIO - class collector: - data = [] - - def write(self, data): - pass - - def append(self, chunk): - self.data.append(chunk) + chunks = [] - def append(fp, cid, *data): - data = b"".join(data) - crc = o32(_crc32(data, _crc32(cid))) - fp.append((cid, data, crc)) + def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None: + byte_data = b"".join(data) + crc = o32(_crc32(byte_data, _crc32(cid))) + chunks.append((cid, byte_data, crc)) - fp = collector() + fp = BytesIO() try: im.encoderinfo = params - _save(im, fp, None, append) + _save(im, fp, "", append) finally: del im.encoderinfo - return fp.data + return chunks # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/PpmImagePlugin.py b/dependencies/WIN/PIL/PpmImagePlugin.py index abf4d65..4e779df 100644 --- a/dependencies/WIN/PIL/PpmImagePlugin.py +++ b/dependencies/WIN/PIL/PpmImagePlugin.py @@ -13,9 +13,15 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations +import math +from typing import IO from . import Image, ImageFile +from ._binary import i16be as i16 +from ._binary import o8 +from ._binary import o32le as o32 # # -------------------------------------------------------------------- @@ -24,11 +30,15 @@ MODES = { # standard + b"P1": "1", + b"P2": "L", + b"P3": "RGB", b"P4": "1", b"P5": "L", b"P6": "RGB", # extensions b"P0CMYK": "CMYK", + b"Pf": "F", # PIL extensions (for test purposes only) b"PyP": "P", b"PyRGBA": "RGBA", @@ -36,8 +46,8 @@ } -def _accept(prefix): - return prefix[0:1] == b"P" and prefix[1] in b"0456y" +def _accept(prefix: bytes) -> bool: + return prefix[0:1] == b"P" and prefix[1] in b"0123456fy" ## @@ -45,111 +55,309 @@ def _accept(prefix): class PpmImageFile(ImageFile.ImageFile): - format = "PPM" format_description = "Pbmplus image" - def _token(self, s=b""): - while True: # read until next whitespace + def _read_magic(self) -> bytes: + assert self.fp is not None + + magic = b"" + # read until whitespace or longest available magic number + for _ in range(6): c = self.fp.read(1) if not c or c in b_whitespace: break - if c > b"\x79": - raise ValueError("Expected ASCII value, found binary") - s = s + c - if len(s) > 9: - raise ValueError("Expected int, got > 9 digits") - return s - - def _open(self): - - # check magic - s = self.fp.read(1) - if s != b"P": - raise SyntaxError("not a PPM file") - magic_number = self._token(s) - mode = MODES[magic_number] - - self.custom_mimetype = { - b"P4": "image/x-portable-bitmap", - b"P5": "image/x-portable-graymap", - b"P6": "image/x-portable-pixmap", - }.get(magic_number) + magic += c + return magic + + def _read_token(self) -> bytes: + assert self.fp is not None + + token = b"" + while len(token) <= 10: # read until next whitespace or limit of 10 characters + c = self.fp.read(1) + if not c: + break + elif c in b_whitespace: # token ended + if not token: + # skip whitespace at start + continue + break + elif c == b"#": + # ignores rest of the line; stops at CR, LF or EOF + while self.fp.read(1) not in b"\r\n": + pass + continue + token += c + if not token: + # Token was not even 1 byte + msg = "Reached EOF while reading header" + raise ValueError(msg) + elif len(token) > 10: + msg = f"Token too long in file header: {token.decode()}" + raise ValueError(msg) + return token + def _open(self) -> None: + assert self.fp is not None + + magic_number = self._read_magic() + try: + mode = MODES[magic_number] + except KeyError: + msg = "not a PPM file" + raise SyntaxError(msg) + self._mode = mode + + if magic_number in (b"P1", b"P4"): + self.custom_mimetype = "image/x-portable-bitmap" + elif magic_number in (b"P2", b"P5"): + self.custom_mimetype = "image/x-portable-graymap" + elif magic_number in (b"P3", b"P6"): + self.custom_mimetype = "image/x-portable-pixmap" + + self._size = int(self._read_token()), int(self._read_token()) + + decoder_name = "raw" + if magic_number in (b"P1", b"P2", b"P3"): + decoder_name = "ppm_plain" + + args: str | tuple[str | int, ...] if mode == "1": - self.mode = "1" - rawmode = "1;I" + args = "1;I" + elif mode == "F": + scale = float(self._read_token()) + if scale == 0.0 or not math.isfinite(scale): + msg = "scale must be finite and non-zero" + raise ValueError(msg) + self.info["scale"] = abs(scale) + + rawmode = "F;32F" if scale < 0 else "F;32BF" + args = (rawmode, 0, -1) else: - self.mode = rawmode = mode - - for ix in range(3): - while True: - while True: - s = self.fp.read(1) - if s not in b_whitespace: - break - if s == b"": - raise ValueError("File does not extend beyond magic number") - if s != b"#": + maxval = int(self._read_token()) + if not 0 < maxval < 65536: + msg = "maxval must be greater than 0 and less than 65536" + raise ValueError(msg) + if maxval > 255 and mode == "L": + self._mode = "I" + + rawmode = mode + if decoder_name != "ppm_plain": + # If maxval matches a bit depth, use the raw decoder directly + if maxval == 65535 and mode == "L": + rawmode = "I;16B" + elif maxval != 255: + decoder_name = "ppm" + + args = rawmode if decoder_name == "raw" else (rawmode, maxval) + self.tile = [ + ImageFile._Tile(decoder_name, (0, 0) + self.size, self.fp.tell(), args) + ] + + +# +# -------------------------------------------------------------------- + + +class PpmPlainDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _comment_spans: bool + + def _read_block(self) -> bytes: + assert self.fd is not None + + return self.fd.read(ImageFile.SAFEBLOCK) + + def _find_comment_end(self, block: bytes, start: int = 0) -> int: + a = block.find(b"\n", start) + b = block.find(b"\r", start) + return min(a, b) if a * b > 0 else max(a, b) # lowest nonnegative index (or -1) + + def _ignore_comments(self, block: bytes) -> bytes: + if self._comment_spans: + # Finish current comment + while block: + comment_end = self._find_comment_end(block) + if comment_end != -1: + # Comment ends in this block + # Delete tail of comment + block = block[comment_end + 1 :] break - s = self.fp.readline() - s = int(self._token(s)) - if ix == 0: - xsize = s - elif ix == 1: - ysize = s - if mode == "1": + else: + # Comment spans whole block + # So read the next block, looking for the end + block = self._read_block() + + # Search for any further comments + self._comment_spans = False + while True: + comment_start = block.find(b"#") + if comment_start == -1: + # No comment found + break + comment_end = self._find_comment_end(block, comment_start) + if comment_end != -1: + # Comment ends in this block + # Delete comment + block = block[:comment_start] + block[comment_end + 1 :] + else: + # Comment continues to next block(s) + block = block[:comment_start] + self._comment_spans = True + break + return block + + def _decode_bitonal(self) -> bytearray: + """ + This is a separate method because in the plain PBM format, all data tokens are + exactly one byte, so the inter-token whitespace is optional. + """ + data = bytearray() + total_bytes = self.state.xsize * self.state.ysize + + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + # eof + break + + block = self._ignore_comments(block) + + tokens = b"".join(block.split()) + for token in tokens: + if token not in (48, 49): + msg = b"Invalid token for this mode: %s" % bytes([token]) + raise ValueError(msg) + data = (data + tokens)[:total_bytes] + invert = bytes.maketrans(b"01", b"\xFF\x00") + return data.translate(invert) + + def _decode_blocks(self, maxval: int) -> bytearray: + data = bytearray() + max_len = 10 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + total_bytes = self.state.xsize * self.state.ysize * bands * out_byte_count + + half_token = b"" + while len(data) != total_bytes: + block = self._read_block() # read next block + if not block: + if half_token: + block = bytearray(b" ") # flush half_token + else: + # eof + break + + block = self._ignore_comments(block) + + if half_token: + block = half_token + block # stitch half_token to new block + half_token = b"" + + tokens = block.split() + + if block and not block[-1:].isspace(): # block might split token + half_token = tokens.pop() # save half token for later + if len(half_token) > max_len: # prevent buildup of half_token + msg = ( + b"Token too long found in data: %s" % half_token[: max_len + 1] + ) + raise ValueError(msg) + + for token in tokens: + if len(token) > max_len: + msg = b"Token too long found in data: %s" % token[: max_len + 1] + raise ValueError(msg) + value = int(token) + if value < 0: + msg_str = f"Channel value is negative: {value}" + raise ValueError(msg_str) + if value > maxval: + msg_str = f"Channel value too large for this mode: {value}" + raise ValueError(msg_str) + value = round(value / maxval * out_max) + data += o32(value) if self.mode == "I" else o8(value) + if len(data) == total_bytes: # finished! break - elif ix == 2: - # maxgrey - if s > 255: - if not mode == "L": - raise ValueError(f"Too many colors for band: {s}") - if s < 2 ** 16: - self.mode = "I" - rawmode = "I;16B" - else: - self.mode = "I" - rawmode = "I;32B" - - self._size = xsize, ysize - self.tile = [("raw", (0, 0, xsize, ysize), self.fp.tell(), (rawmode, 0, 1))] + return data + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + self._comment_spans = False + if self.mode == "1": + data = self._decode_bitonal() + rawmode = "1;8" + else: + maxval = self.args[-1] + data = self._decode_blocks(maxval) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 + + +class PpmDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + data = bytearray() + maxval = self.args[-1] + in_byte_count = 1 if maxval < 256 else 2 + out_byte_count = 4 if self.mode == "I" else 1 + out_max = 65535 if self.mode == "I" else 255 + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands * out_byte_count + while len(data) < dest_length: + pixels = self.fd.read(in_byte_count * bands) + if len(pixels) < in_byte_count * bands: + # eof + break + for b in range(bands): + value = ( + pixels[b] if in_byte_count == 1 else i16(pixels, b * in_byte_count) + ) + value = min(out_max, round(value / maxval * out_max)) + data += o32(value) if self.mode == "I" else o8(value) + rawmode = "I;32" if self.mode == "I" else self.mode + self.set_as_raw(bytes(data), rawmode) + return -1, 0 # # -------------------------------------------------------------------- -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": rawmode, head = "L", b"P5" - elif im.mode == "I": - if im.getextrema()[1] < 2 ** 16: - rawmode, head = "I;16B", b"P5" - else: - rawmode, head = "I;32B", b"P5" - elif im.mode == "RGB": - rawmode, head = "RGB", b"P6" - elif im.mode == "RGBA": + elif im.mode in ("I", "I;16"): + rawmode, head = "I;16B", b"P5" + elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" + elif im.mode == "F": + rawmode, head = "F;32F", b"Pf" else: - raise OSError(f"cannot write mode {im.mode} as PPM") - fp.write(head + ("\n%d %d\n" % im.size).encode("ascii")) + msg = f"cannot write mode {im.mode} as PPM" + raise OSError(msg) + fp.write(head + b"\n%d %d\n" % im.size) if head == b"P6": fp.write(b"255\n") - if head == b"P5": + elif head == b"P5": if rawmode == "L": fp.write(b"255\n") - elif rawmode == "I;16B": + else: fp.write(b"65535\n") - elif rawmode == "I;32B": - fp.write(b"2147483648\n") - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) - - # ALTERNATIVE: save via builtin debug function - # im._dump(filename) + elif head == b"Pf": + fp.write(b"-1.0\n") + row_order = -1 if im.mode == "F" else 1 + ImageFile._save( + im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))] + ) # @@ -159,6 +367,9 @@ def _save(im, fp, filename): Image.register_open(PpmImageFile.format, PpmImageFile, _accept) Image.register_save(PpmImageFile.format, _save) -Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"]) +Image.register_decoder("ppm", PpmDecoder) +Image.register_decoder("ppm_plain", PpmPlainDecoder) + +Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"]) Image.register_mime(PpmImageFile.format, "image/x-portable-anymap") diff --git a/dependencies/WIN/PIL/PsdImagePlugin.py b/dependencies/WIN/PIL/PsdImagePlugin.py index 04b21e3..8ff5e39 100644 --- a/dependencies/WIN/PIL/PsdImagePlugin.py +++ b/dependencies/WIN/PIL/PsdImagePlugin.py @@ -15,14 +15,18 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io +from functools import cached_property +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 +from ._binary import si32be as si32 MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -42,7 +46,7 @@ # read PSD images -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"8BPS" @@ -51,13 +55,11 @@ def _accept(prefix): class PsdImageFile(ImageFile.ImageFile): - format = "PSD" format_description = "Adobe Photoshop" _close_exclusive_fp_after_loading = False - def _open(self): - + def _open(self) -> None: read = self.fp.read # @@ -65,7 +67,8 @@ def _open(self): s = read(26) if not _accept(s) or i16(s, 4) != 1: - raise SyntaxError("not a PSD file") + msg = "not a PSD file" + raise SyntaxError(msg) psd_bits = i16(s, 22) psd_channels = i16(s, 12) @@ -74,9 +77,13 @@ def _open(self): mode, channels = MODES[(psd_mode, psd_bits)] if channels > psd_channels: - raise OSError("not enough channels") + msg = "not enough channels" + raise OSError(msg) + if mode == "RGB" and psd_channels == 4: + mode = "RGBA" + channels = 4 - self.mode = mode + self._mode = mode self._size = i32(s, 18), i32(s, 14) # @@ -113,18 +120,17 @@ def _open(self): # # layer and mask information - self.layers = [] + self._layers_position = None size = i32(read(4)) if size: end = self.fp.tell() + size size = i32(read(4)) if size: - _layer_data = io.BytesIO(ImageFile._safe_read(self.fp, size)) - self.layers = _layerinfo(_layer_data, size) + self._layers_position = self.fp.tell() + self._layers_size = size self.fp.seek(end) - self.n_frames = len(self.layers) - self.is_animated = self.n_frames > 1 + self._n_frames: int | None = None # # image descriptor @@ -132,144 +138,147 @@ def _open(self): self.tile = _maketile(self.fp, mode, (0, 0) + self.size, channels) # keep the file open - self.__fp = self.fp + self._fp = self.fp self.frame = 1 self._min_frame = 1 - def seek(self, layer): + @cached_property + def layers( + self, + ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: + layers = [] + if self._layers_position is not None: + self._fp.seek(self._layers_position) + _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) + layers = _layerinfo(_layer_data, self._layers_size) + self._n_frames = len(layers) + return layers + + @property + def n_frames(self) -> int: + if self._n_frames is None: + self._n_frames = len(self.layers) + return self._n_frames + + @property + def is_animated(self) -> bool: + return len(self.layers) > 1 + + def seek(self, layer: int) -> None: if not self._seek_check(layer): return # seek to given layer (1..max) try: - name, mode, bbox, tile = self.layers[layer - 1] - self.mode = mode + _, mode, _, tile = self.layers[layer - 1] + self._mode = mode self.tile = tile self.frame = layer - self.fp = self.__fp - return name, bbox + self.fp = self._fp except IndexError as e: - raise EOFError("no such layer") from e + msg = "no such layer" + raise EOFError(msg) from e - def tell(self): + def tell(self) -> int: # return layer number (0=image, 1..max=layers) return self.frame - def load_prepare(self): - # create image memory if necessary - if not self.im or self.im.mode != self.mode or self.im.size != self.size: - self.im = Image.core.fill(self.mode, self.size, 0) - # create palette (optional) - if self.mode == "P": - Image.Image.load(self) - - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - -def _layerinfo(fp, ct_bytes): +def _layerinfo( + fp: IO[bytes], ct_bytes: int +) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: # read layerinfo block layers = [] - def read(size): + def read(size: int) -> bytes: return ImageFile._safe_read(fp, size) ct = si16(read(2)) # sanity check if ct_bytes < (abs(ct) * 20): - raise SyntaxError("Layer block too short for number of layers requested") - - for i in range(abs(ct)): + msg = "Layer block too short for number of layers requested" + raise SyntaxError(msg) + for _ in range(abs(ct)): # bounding box - y0 = i32(read(4)) - x0 = i32(read(4)) - y1 = i32(read(4)) - x1 = i32(read(4)) + y0 = si32(read(4)) + x0 = si32(read(4)) + y1 = si32(read(4)) + x1 = si32(read(4)) # image info - info = [] - mode = [] + bands = [] ct_types = i16(read(2)) - types = list(range(ct_types)) - if len(types) > 4: + if ct_types > 4: + fp.seek(ct_types * 6 + 12, io.SEEK_CUR) + size = i32(read(4)) + fp.seek(size, io.SEEK_CUR) continue - for i in types: + for _ in range(ct_types): type = i16(read(2)) if type == 65535: - m = "A" + b = "A" else: - m = "RGBA"[type] + b = "RGBA"[type] - mode.append(m) - size = i32(read(4)) - info.append((m, size)) + bands.append(b) + read(4) # size # figure out the image mode - mode.sort() - if mode == ["R"]: + bands.sort() + if bands == ["R"]: mode = "L" - elif mode == ["B", "G", "R"]: + elif bands == ["B", "G", "R"]: mode = "RGB" - elif mode == ["A", "B", "G", "R"]: + elif bands == ["A", "B", "G", "R"]: mode = "RGBA" else: - mode = None # unknown + mode = "" # unknown # skip over blend flags and extra information read(12) # filler name = "" size = i32(read(4)) # length of the extra data field - combined = 0 if size: data_end = fp.tell() + size length = i32(read(4)) if length: fp.seek(length - 16, io.SEEK_CUR) - combined += length + 4 length = i32(read(4)) if length: fp.seek(length, io.SEEK_CUR) - combined += length + 4 length = i8(read(1)) if length: # Don't know the proper encoding, # Latin-1 should be a good guess name = read(length).decode("latin-1", "replace") - combined += length + 1 fp.seek(data_end) layers.append((name, mode, (x0, y0, x1, y1))) # get tiles - i = 0 - for name, mode, bbox in layers: + layerinfo = [] + for i, (name, mode, bbox) in enumerate(layers): tile = [] for m in mode: t = _maketile(fp, m, bbox, 1) if t: tile.extend(t) - layers[i] = name, mode, bbox, tile - i += 1 + layerinfo.append((name, mode, bbox, tile)) - return layers + return layerinfo -def _maketile(file, mode, bbox, channels): - - tile = None +def _maketile( + file: IO[bytes], mode: str, bbox: tuple[int, int, int, int], channels: int +) -> list[ImageFile._Tile]: + tiles = [] read = file.read compression = i16(read(2)) @@ -282,26 +291,24 @@ def _maketile(file, mode, bbox, channels): if compression == 0: # # raw compression - tile = [] for channel in range(channels): layer = mode[channel] if mode == "CMYK": layer += ";I" - tile.append(("raw", bbox, offset, layer)) + tiles.append(ImageFile._Tile("raw", bbox, offset, layer)) offset = offset + xsize * ysize elif compression == 1: # # packbits compression i = 0 - tile = [] bytecount = read(channels * ysize * 2) offset = file.tell() for channel in range(channels): layer = mode[channel] if mode == "CMYK": layer += ";I" - tile.append(("packbits", bbox, offset, layer)) + tiles.append(ImageFile._Tile("packbits", bbox, offset, layer)) for y in range(ysize): offset = offset + i16(bytecount, i) i += 2 @@ -311,7 +318,7 @@ def _maketile(file, mode, bbox, channels): if offset & 1: read(1) # padding - return tile + return tiles # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/PyAccess.py b/dependencies/WIN/PIL/PyAccess.py deleted file mode 100644 index eeaa0cc..0000000 --- a/dependencies/WIN/PIL/PyAccess.py +++ /dev/null @@ -1,353 +0,0 @@ -# -# The Python Imaging Library -# Pillow fork -# -# Python implementation of the PixelAccess Object -# -# Copyright (c) 1997-2009 by Secret Labs AB. All rights reserved. -# Copyright (c) 1995-2009 by Fredrik Lundh. -# Copyright (c) 2013 Eric Soroos -# -# See the README file for information on usage and redistribution -# - -# Notes: -# -# * Implements the pixel access object following Access. -# * Does not implement the line functions, as they don't appear to be used -# * Taking only the tuple form, which is used from python. -# * Fill.c uses the integer form, but it's still going to use the old -# Access.c implementation. -# - -import logging -import sys - -try: - from cffi import FFI - - defs = """ - struct Pixel_RGBA { - unsigned char r,g,b,a; - }; - struct Pixel_I16 { - unsigned char l,r; - }; - """ - ffi = FFI() - ffi.cdef(defs) -except ImportError as ex: - # Allow error import for doc purposes, but error out when accessing - # anything in core. - from ._util import deferred_error - - FFI = ffi = deferred_error(ex) - -logger = logging.getLogger(__name__) - - -class PyAccess: - def __init__(self, img, readonly=False): - vals = dict(img.im.unsafe_ptrs) - self.readonly = readonly - self.image8 = ffi.cast("unsigned char **", vals["image8"]) - self.image32 = ffi.cast("int **", vals["image32"]) - self.image = ffi.cast("unsigned char **", vals["image"]) - self.xsize, self.ysize = img.im.size - self._img = img - - # Keep pointer to im object to prevent dereferencing. - self._im = img.im - if self._im.mode == "P": - self._palette = img.palette - - # Debugging is polluting test traces, only useful here - # when hacking on PyAccess - # logger.debug("%s", vals) - self._post_init() - - def _post_init(self): - pass - - def __setitem__(self, xy, color): - """ - Modifies the pixel at x,y. The color is given as a single - numerical value for single band images, and a tuple for - multi-band images - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :param color: The pixel value. - """ - if self.readonly: - raise ValueError("Attempt to putpixel a read only image") - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - - if ( - self._im.mode == "P" - and isinstance(color, (list, tuple)) - and len(color) in [3, 4] - ): - # RGB or RGBA value for a P image - color = self._palette.getcolor(color, self._img) - - return self.set_pixel(x, y, color) - - def __getitem__(self, xy): - """ - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multiple band - images - - :param xy: The pixel coordinate, given as (x, y). See - :ref:`coordinate-system`. - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. - """ - (x, y) = xy - if x < 0: - x = self.xsize + x - if y < 0: - y = self.ysize + y - (x, y) = self.check_xy((x, y)) - return self.get_pixel(x, y) - - putpixel = __setitem__ - getpixel = __getitem__ - - def check_xy(self, xy): - (x, y) = xy - if not (0 <= x < self.xsize and 0 <= y < self.ysize): - raise ValueError("pixel location out of range") - return xy - - -class _PyAccess32_2(PyAccess): - """PA, LA, stored in first and last bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.a) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.a = min(color[1], 255) - - -class _PyAccess32_3(PyAccess): - """RGB and friends, stored in the first three bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.g, pixel.b) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = 255 - - -class _PyAccess32_4(PyAccess): - """RGBA etc, all 4 bytes of a 32 bit word""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return (pixel.r, pixel.g, pixel.b, pixel.a) - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - # tuple - pixel.r = min(color[0], 255) - pixel.g = min(color[1], 255) - pixel.b = min(color[2], 255) - pixel.a = min(color[3], 255) - - -class _PyAccess8(PyAccess): - """1, L, P, 8 bit images stored as uint8""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image8 - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 255) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 255) - - -class _PyAccessI16_N(PyAccess): - """I;16 access, native bitendian without conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("unsigned short **", self.image) - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # integer - self.pixels[y][x] = min(color, 65535) - except TypeError: - # tuple - self.pixels[y][x] = min(color[0], 65535) - - -class _PyAccessI16_L(PyAccess): - """I;16L access, with conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return pixel.l + pixel.r * 256 - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except TypeError: - color = min(color[0], 65535) - - pixel.l = color & 0xFF # noqa: E741 - pixel.r = color >> 8 - - -class _PyAccessI16_B(PyAccess): - """I;16B access, with conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - - def get_pixel(self, x, y): - pixel = self.pixels[y][x] - return pixel.l * 256 + pixel.r - - def set_pixel(self, x, y, color): - pixel = self.pixels[y][x] - try: - color = min(color, 65535) - except Exception: - color = min(color[0], 65535) - - pixel.l = color >> 8 # noqa: E741 - pixel.r = color & 0xFF - - -class _PyAccessI32_N(PyAccess): - """Signed Int32 access, native endian""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - self.pixels[y][x] = color - - -class _PyAccessI32_Swap(PyAccess): - """I;32L/B access, with byteswapping conversion""" - - def _post_init(self, *args, **kwargs): - self.pixels = self.image32 - - def reverse(self, i): - orig = ffi.new("int *", i) - chars = ffi.cast("unsigned char *", orig) - chars[0], chars[1], chars[2], chars[3] = chars[3], chars[2], chars[1], chars[0] - return ffi.cast("int *", chars)[0] - - def get_pixel(self, x, y): - return self.reverse(self.pixels[y][x]) - - def set_pixel(self, x, y, color): - self.pixels[y][x] = self.reverse(color) - - -class _PyAccessF(PyAccess): - """32 bit float access""" - - def _post_init(self, *args, **kwargs): - self.pixels = ffi.cast("float **", self.image32) - - def get_pixel(self, x, y): - return self.pixels[y][x] - - def set_pixel(self, x, y, color): - try: - # not a tuple - self.pixels[y][x] = color - except TypeError: - # tuple - self.pixels[y][x] = color[0] - - -mode_map = { - "1": _PyAccess8, - "L": _PyAccess8, - "P": _PyAccess8, - "LA": _PyAccess32_2, - "La": _PyAccess32_2, - "PA": _PyAccess32_2, - "RGB": _PyAccess32_3, - "LAB": _PyAccess32_3, - "HSV": _PyAccess32_3, - "YCbCr": _PyAccess32_3, - "RGBA": _PyAccess32_4, - "RGBa": _PyAccess32_4, - "RGBX": _PyAccess32_4, - "CMYK": _PyAccess32_4, - "F": _PyAccessF, - "I": _PyAccessI32_N, -} - -if sys.byteorder == "little": - mode_map["I;16"] = _PyAccessI16_N - mode_map["I;16L"] = _PyAccessI16_N - mode_map["I;16B"] = _PyAccessI16_B - - mode_map["I;32L"] = _PyAccessI32_N - mode_map["I;32B"] = _PyAccessI32_Swap -else: - mode_map["I;16"] = _PyAccessI16_L - mode_map["I;16L"] = _PyAccessI16_L - mode_map["I;16B"] = _PyAccessI16_N - - mode_map["I;32L"] = _PyAccessI32_Swap - mode_map["I;32B"] = _PyAccessI32_N - - -def new(img, readonly=False): - access_type = mode_map.get(img.mode, None) - if not access_type: - logger.debug("PyAccess Not Implemented: %s", img.mode) - return None - return access_type(img, readonly) diff --git a/dependencies/WIN/PIL/QoiImagePlugin.py b/dependencies/WIN/PIL/QoiImagePlugin.py new file mode 100644 index 0000000..01cc868 --- /dev/null +++ b/dependencies/WIN/PIL/QoiImagePlugin.py @@ -0,0 +1,115 @@ +# +# The Python Imaging Library. +# +# QOI support for PIL +# +# See the README file for information on usage and redistribution. +# +from __future__ import annotations + +import os + +from . import Image, ImageFile +from ._binary import i32be as i32 + + +def _accept(prefix: bytes) -> bool: + return prefix[:4] == b"qoif" + + +class QoiImageFile(ImageFile.ImageFile): + format = "QOI" + format_description = "Quite OK Image" + + def _open(self) -> None: + if not _accept(self.fp.read(4)): + msg = "not a QOI file" + raise SyntaxError(msg) + + self._size = i32(self.fp.read(4)), i32(self.fp.read(4)) + + channels = self.fp.read(1)[0] + self._mode = "RGB" if channels == 3 else "RGBA" + + self.fp.seek(1, os.SEEK_CUR) # colorspace + self.tile = [ImageFile._Tile("qoi", (0, 0) + self._size, self.fp.tell())] + + +class QoiDecoder(ImageFile.PyDecoder): + _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} + + def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: + self._previous_pixel = value + + r, g, b, a = value + hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64 + self._previously_seen_pixels[hash_value] = value + + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + + self._previously_seen_pixels = {} + self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) + + data = bytearray() + bands = Image.getmodebands(self.mode) + dest_length = self.state.xsize * self.state.ysize * bands + while len(data) < dest_length: + byte = self.fd.read(1)[0] + value: bytes | bytearray + if byte == 0b11111110 and self._previous_pixel: # QOI_OP_RGB + value = bytearray(self.fd.read(3)) + self._previous_pixel[3:] + elif byte == 0b11111111: # QOI_OP_RGBA + value = self.fd.read(4) + else: + op = byte >> 6 + if op == 0: # QOI_OP_INDEX + op_index = byte & 0b00111111 + value = self._previously_seen_pixels.get( + op_index, bytearray((0, 0, 0, 0)) + ) + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF + value = bytearray( + ( + (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) + % 256, + (self._previous_pixel[1] + ((byte & 0b00001100) >> 2) - 2) + % 256, + (self._previous_pixel[2] + (byte & 0b00000011) - 2) % 256, + self._previous_pixel[3], + ) + ) + elif op == 2 and self._previous_pixel: # QOI_OP_LUMA + second_byte = self.fd.read(1)[0] + diff_green = (byte & 0b00111111) - 32 + diff_red = ((second_byte & 0b11110000) >> 4) - 8 + diff_blue = (second_byte & 0b00001111) - 8 + + value = bytearray( + tuple( + (self._previous_pixel[i] + diff_green + diff) % 256 + for i, diff in enumerate((diff_red, 0, diff_blue)) + ) + ) + value += self._previous_pixel[3:] + elif op == 3 and self._previous_pixel: # QOI_OP_RUN + run_length = (byte & 0b00111111) + 1 + value = self._previous_pixel + if bands == 3: + value = value[:3] + data += value * run_length + continue + self._add_to_previous_pixels(value) + + if bands == 3: + value = value[:3] + data += value + self.set_as_raw(data) + return -1, 0 + + +Image.register_open(QoiImageFile.format, QoiImageFile, _accept) +Image.register_decoder("qoi", QoiDecoder) +Image.register_extension(QoiImageFile.format, ".qoi") diff --git a/dependencies/WIN/PIL/SgiImagePlugin.py b/dependencies/WIN/PIL/SgiImagePlugin.py index 5f1ef6e..44254b7 100644 --- a/dependencies/WIN/PIL/SgiImagePlugin.py +++ b/dependencies/WIN/PIL/SgiImagePlugin.py @@ -20,17 +20,18 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import os import struct +from typing import IO from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import o8 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 2 and i16(prefix) == 474 @@ -49,18 +50,19 @@ def _accept(prefix): ## # Image plugin for SGI images. class SgiImageFile(ImageFile.ImageFile): - format = "SGI" format_description = "SGI Image File Format" - def _open(self): - + def _open(self) -> None: # HEAD + assert self.fp is not None + headlen = 512 s = self.fp.read(headlen) if not _accept(s): - raise ValueError("Not an SGI image file") + msg = "Not an SGI image file" + raise ValueError(msg) # compression : verbatim or RLE compression = s[2] @@ -91,10 +93,11 @@ def _open(self): pass if rawmode == "": - raise ValueError("Unsupported SGI image mode") + msg = "Unsupported SGI image mode" + raise ValueError(msg) self._size = xsize, ysize - self.mode = rawmode.split(";")[0] + self._mode = rawmode.split(";")[0] if self.mode == "RGB": self.custom_mimetype = "image/rgb" @@ -106,25 +109,35 @@ def _open(self): pagesize = xsize * ysize * bpc if bpc == 2: self.tile = [ - ("SGI16", (0, 0) + self.size, headlen, (self.mode, 0, orientation)) + ImageFile._Tile( + "SGI16", + (0, 0) + self.size, + headlen, + (self.mode, 0, orientation), + ) ] else: self.tile = [] offset = headlen for layer in self.mode: self.tile.append( - ("raw", (0, 0) + self.size, offset, (layer, 0, orientation)) + ImageFile._Tile( + "raw", (0, 0) + self.size, offset, (layer, 0, orientation) + ) ) offset += pagesize elif compression == 1: self.tile = [ - ("sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc)) + ImageFile._Tile( + "sgi_rle", (0, 0) + self.size, headlen, (rawmode, orientation, bpc) + ) ] -def _save(im, fp, filename): - if im.mode != "RGB" and im.mode != "RGBA" and im.mode != "L": - raise ValueError("Unsupported SGI image mode") +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + if im.mode not in {"RGB", "RGBA", "L"}: + msg = "Unsupported SGI image mode" + raise ValueError(msg) # Get the keyword arguments info = im.encoderinfo @@ -133,12 +146,13 @@ def _save(im, fp, filename): bpc = info.get("bpc", 1) if bpc not in (1, 2): - raise ValueError("Unsupported number of bytes per pixel") + msg = "Unsupported number of bytes per pixel" + raise ValueError(msg) # Flip the image, since the origin of SGI file is the bottom-left corner orientation = -1 # Define the file as SGI File Format - magicNumber = 474 + magic_number = 474 # Run-Length Encoding Compression - Unsupported at this time rle = 0 @@ -153,25 +167,25 @@ def _save(im, fp, filename): # Z Dimension: Number of channels z = len(im.mode) - if dim == 1 or dim == 2: + if dim in {1, 2}: z = 1 # assert we've got the right number of bands. if len(im.getbands()) != z: - raise ValueError( - f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" - ) + msg = f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}" + raise ValueError(msg) # Minimum Byte value pinmin = 0 # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - imgName = os.path.splitext(os.path.basename(filename))[0] - imgName = imgName.encode("ascii", "ignore") + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 - fp.write(struct.pack(">h", magicNumber)) + fp.write(struct.pack(">h", magic_number)) fp.write(o8(rle)) fp.write(o8(bpc)) fp.write(struct.pack(">H", dim)) @@ -181,8 +195,8 @@ def _save(im, fp, filename): fp.write(struct.pack(">l", pinmin)) fp.write(struct.pack(">l", pinmax)) fp.write(struct.pack("4s", b"")) # dummy - fp.write(struct.pack("79s", imgName)) # truncates to 79 chars - fp.write(struct.pack("s", b"")) # force null byte after imgname + fp.write(struct.pack("79s", img_name)) # truncates to 79 chars + fp.write(struct.pack("s", b"")) # force null byte after img_name fp.write(struct.pack(">l", colormap)) fp.write(struct.pack("404s", b"")) # dummy @@ -200,7 +214,10 @@ def _save(im, fp, filename): class SGI16Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]: + assert self.fd is not None + assert self.im is not None + rawmode, stride, orientation = self.args pagesize = self.state.xsize * self.state.ysize zsize = len(self.mode) diff --git a/dependencies/WIN/PIL/SpiderImagePlugin.py b/dependencies/WIN/PIL/SpiderImagePlugin.py index 062af9f..3a87d00 100644 --- a/dependencies/WIN/PIL/SpiderImagePlugin.py +++ b/dependencies/WIN/PIL/SpiderImagePlugin.py @@ -15,7 +15,7 @@ # ## -# Image plugin for the Spider image format. This format is is used +# Image plugin for the Spider image format. This format is used # by the SPIDER software, in processing image data from electron # microscopy and tomography. ## @@ -32,14 +32,17 @@ # Details about the Spider image format: # https://spider.wadsworth.org/spider_doc/spider/docs/image_doc.html # +from __future__ import annotations + import os import struct import sys +from typing import IO, TYPE_CHECKING, Any, cast -from PIL import Image, ImageFile +from . import Image, ImageFile -def isInt(f): +def isInt(f: Any) -> int: try: i = int(f) if f - i == 0: @@ -59,7 +62,7 @@ def isInt(f): # otherwise returns 0 -def isSpiderHeader(t): +def isSpiderHeader(t: tuple[float, ...]) -> int: h = (99,) + t # add 1 value so can use spider header index start=1 # header values 1,2,5,12,13,22,23 should be integers for i in [1, 2, 5, 12, 13, 22, 23]: @@ -79,7 +82,7 @@ def isSpiderHeader(t): return labbyt -def isSpiderImage(filename): +def isSpiderImage(filename: str) -> int: with open(filename, "rb") as fp: f = fp.read(92) # read 23 * 4 bytes t = struct.unpack(">23f", f) # try big-endian first @@ -91,12 +94,11 @@ def isSpiderImage(filename): class SpiderImageFile(ImageFile.ImageFile): - format = "SPIDER" format_description = "Spider 2D image" _close_exclusive_fp_after_loading = False - def _open(self): + def _open(self) -> None: # check header n = 27 * 4 # read 27 float values f = self.fp.read(n) @@ -110,14 +112,17 @@ def _open(self): t = struct.unpack("<27f", f) # little-endian hdrlen = isSpiderHeader(t) if hdrlen == 0: - raise SyntaxError("not a valid Spider file") + msg = "not a valid Spider file" + raise SyntaxError(msg) except struct.error as e: - raise SyntaxError("not a valid Spider file") from e + msg = "not a valid Spider file" + raise SyntaxError(msg) from e h = (99,) + t # add 1 value : spider header index starts at 1 iform = int(h[5]) if iform != 1: - raise SyntaxError("not a Spider 2D image") + msg = "not a Spider 2D image" + raise SyntaxError(msg) self._size = int(h[12]), int(h[2]) # size in pixels (width, height) self.istack = int(h[24]) @@ -140,115 +145,114 @@ def _open(self): offset = hdrlen + self.stkoffset self.istack = 2 # So Image knows it's still a stack else: - raise SyntaxError("inconsistent stack header values") + msg = "inconsistent stack header values" + raise SyntaxError(msg) if self.bigendian: self.rawmode = "F;32BF" else: self.rawmode = "F;32F" - self.mode = "F" + self._mode = "F" - self.tile = [("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1))] - self.__fp = self.fp # FIXME: hack + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, offset, self.rawmode)] + self._fp = self.fp # FIXME: hack @property - def n_frames(self): + def n_frames(self) -> int: return self._nimages @property - def is_animated(self): + def is_animated(self) -> bool: return self._nimages > 1 # 1st image index is zero (although SPIDER imgnumber starts at 1) - def tell(self): + def tell(self) -> int: if self.imgnumber < 1: return 0 else: return self.imgnumber - 1 - def seek(self, frame): + def seek(self, frame: int) -> None: if self.istack == 0: - raise EOFError("attempt to seek in a non-stack file") + msg = "attempt to seek in a non-stack file" + raise EOFError(msg) if not self._seek_check(frame): return self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) - self.fp = self.__fp + self.fp = self._fp self.fp.seek(self.stkoffset) self._open() # returns a byte image after rescaling to 0..255 - def convert2byte(self, depth=255): - (minimum, maximum) = self.getextrema() - m = 1 + def convert2byte(self, depth: int = 255) -> Image.Image: + extrema = self.getextrema() + assert isinstance(extrema[0], float) + minimum, maximum = cast(tuple[float, float], extrema) + m: float = 1 if maximum != minimum: m = depth / (maximum - minimum) b = -m * minimum - return self.point(lambda i, m=m, b=b: i * m + b).convert("L") + return self.point(lambda i: i * m + b).convert("L") + + if TYPE_CHECKING: + from . import ImageTk # returns a ImageTk.PhotoImage object, after rescaling to 0..255 - def tkPhotoImage(self): - from PIL import ImageTk + def tkPhotoImage(self) -> ImageTk.PhotoImage: + from . import ImageTk return ImageTk.PhotoImage(self.convert2byte(), palette=256) - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # -------------------------------------------------------------------- # Image series + # given a list of filenames, return a list of images -def loadImageSeries(filelist=None): +def loadImageSeries(filelist: list[str] | None = None) -> list[Image.Image] | None: """create a list of :py:class:`~PIL.Image.Image` objects for use in a montage""" if filelist is None or len(filelist) < 1: - return + return None - imglist = [] + byte_imgs = [] for img in filelist: if not os.path.exists(img): print(f"unable to find {img}") continue try: with Image.open(img) as im: - im = im.convert2byte() + assert isinstance(im, SpiderImageFile) + byte_im = im.convert2byte() except Exception: if not isSpiderImage(img): - print(img + " is not a Spider image file") + print(f"{img} is not a Spider image file") continue - im.info["filename"] = img - imglist.append(im) - return imglist + byte_im.info["filename"] = img + byte_imgs.append(byte_im) + return byte_imgs # -------------------------------------------------------------------- # For saving images in Spider format -def makeSpiderHeader(im): +def makeSpiderHeader(im: Image.Image) -> list[bytes]: nsam, nrow = im.size lenbyt = nsam * 4 # There are labrec records in the header labrec = int(1024 / lenbyt) if 1024 % lenbyt != 0: labrec += 1 labbyt = labrec * lenbyt - hdr = [] nvalues = int(labbyt / 4) - for i in range(nvalues): - hdr.append(0.0) - - if len(hdr) < 23: + if nvalues < 23: return [] + hdr = [0.0] * nvalues + # NB these are Fortran indices hdr[1] = 1.0 # nslice (=1 for an image) hdr[2] = float(nrow) # number of rows per slice + hdr[3] = float(nrow) # number of records in the image hdr[5] = 1.0 # iform for 2D image hdr[12] = float(nsam) # number of pixels per line hdr[13] = float(labrec) # number of records in file header @@ -259,30 +263,29 @@ def makeSpiderHeader(im): hdr = hdr[1:] hdr.append(0.0) # pack binary data into a string - hdrstr = [] - for v in hdr: - hdrstr.append(struct.pack("f", v)) - return hdrstr + return [struct.pack("f", v) for v in hdr] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode[0] != "F": im = im.convert("F") hdr = makeSpiderHeader(im) if len(hdr) < 256: - raise OSError("Error creating Spider header") + msg = "Error creating Spider header" + raise OSError(msg) # write the SPIDER header fp.writelines(hdr) rawmode = "F;32NF" # 32-bit native floating point - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) + ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) -def _save_spider(im, fp, filename): +def _save_spider(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # get the filename extension and register it with Image - ext = os.path.splitext(filename)[1] + filename_ext = os.path.splitext(filename)[1] + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext Image.register_extension(SpiderImageFile.format, ext) _save(im, fp, filename) @@ -294,7 +297,6 @@ def _save_spider(im, fp, filename): Image.register_save(SpiderImageFile.format, _save_spider) if __name__ == "__main__": - if len(sys.argv) < 2: print("Syntax: python3 SpiderImagePlugin.py [infile] [outfile]") sys.exit() @@ -305,10 +307,10 @@ def _save_spider(im, fp, filename): sys.exit() with Image.open(filename) as im: - print("image: " + str(im)) - print("format: " + str(im.format)) - print("size: " + str(im.size)) - print("mode: " + str(im.mode)) + print(f"image: {im}") + print(f"format: {im.format}") + print(f"size: {im.size}") + print(f"mode: {im.mode}") print("max, min: ", end=" ") print(im.getextrema()) @@ -316,7 +318,7 @@ def _save_spider(im, fp, filename): outfile = sys.argv[2] # perform some image operation - im = im.transpose(Image.FLIP_LEFT_RIGHT) + im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) print( f"saving a flipped version of {os.path.basename(filename)} " f"as {outfile} " diff --git a/dependencies/WIN/PIL/SunImagePlugin.py b/dependencies/WIN/PIL/SunImagePlugin.py index c03759a..8912379 100644 --- a/dependencies/WIN/PIL/SunImagePlugin.py +++ b/dependencies/WIN/PIL/SunImagePlugin.py @@ -15,13 +15,13 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations from . import Image, ImageFile, ImagePalette from ._binary import i32be as i32 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 4 and i32(prefix) == 0x59A66A95 @@ -30,12 +30,10 @@ def _accept(prefix): class SunImageFile(ImageFile.ImageFile): - format = "SUN" format_description = "Sun Raster File" - def _open(self): - + def _open(self) -> None: # The Sun Raster file header is 32 bytes in length # and has the following format: @@ -51,10 +49,13 @@ def _open(self): # DWORD ColorMapLength; /* Size of the color map in bytes */ # } SUNRASTER; + assert self.fp is not None + # HEAD s = self.fp.read(32) if not _accept(s): - raise SyntaxError("not an SUN raster file") + msg = "not an SUN raster file" + raise SyntaxError(msg) offset = 32 @@ -67,35 +68,38 @@ def _open(self): palette_length = i32(s, 28) if depth == 1: - self.mode, rawmode = "1", "1;I" + self._mode, rawmode = "1", "1;I" elif depth == 4: - self.mode, rawmode = "L", "L;4" + self._mode, rawmode = "L", "L;4" elif depth == 8: - self.mode = rawmode = "L" + self._mode = rawmode = "L" elif depth == 24: if file_type == 3: - self.mode, rawmode = "RGB", "RGB" + self._mode, rawmode = "RGB", "RGB" else: - self.mode, rawmode = "RGB", "BGR" + self._mode, rawmode = "RGB", "BGR" elif depth == 32: if file_type == 3: - self.mode, rawmode = "RGB", "RGBX" + self._mode, rawmode = "RGB", "RGBX" else: - self.mode, rawmode = "RGB", "BGRX" + self._mode, rawmode = "RGB", "BGRX" else: - raise SyntaxError("Unsupported Mode/Bit Depth") + msg = "Unsupported Mode/Bit Depth" + raise SyntaxError(msg) if palette_length: if palette_length > 1024: - raise SyntaxError("Unsupported Color Palette Length") + msg = "Unsupported Color Palette Length" + raise SyntaxError(msg) if palette_type != 1: - raise SyntaxError("Unsupported Palette Type") + msg = "Unsupported Palette Type" + raise SyntaxError(msg) offset = offset + palette_length self.palette = ImagePalette.raw("RGB;L", self.fp.read(palette_length)) if self.mode == "L": - self.mode = "P" + self._mode = "P" rawmode = rawmode.replace("L", "P") # 16 bit boundaries on stride @@ -120,11 +124,16 @@ def _open(self): # (https://www.fileformat.info/format/sunraster/egff.htm) if file_type in (0, 1, 3, 4, 5): - self.tile = [("raw", (0, 0) + self.size, offset, (rawmode, stride))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride)) + ] elif file_type == 2: - self.tile = [("sun_rle", (0, 0) + self.size, offset, rawmode)] + self.tile = [ + ImageFile._Tile("sun_rle", (0, 0) + self.size, offset, rawmode) + ] else: - raise SyntaxError("Unsupported Sun Raster file type") + msg = "Unsupported Sun Raster file type" + raise SyntaxError(msg) # diff --git a/dependencies/WIN/PIL/TarIO.py b/dependencies/WIN/PIL/TarIO.py index d108362..779288b 100644 --- a/dependencies/WIN/PIL/TarIO.py +++ b/dependencies/WIN/PIL/TarIO.py @@ -13,16 +13,17 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import io from . import ContainerIO -class TarIO(ContainerIO.ContainerIO): +class TarIO(ContainerIO.ContainerIO[bytes]): """A file object that provides read access to a given member of a TAR file.""" - def __init__(self, tarfile, file): + def __init__(self, tarfile: str, file: str) -> None: """ Create file object. @@ -32,15 +33,16 @@ def __init__(self, tarfile, file): self.fh = open(tarfile, "rb") while True: - s = self.fh.read(512) if len(s) != 512: - raise OSError("unexpected end of tar file") + msg = "unexpected end of tar file" + raise OSError(msg) name = s[:100].decode("utf-8") i = name.find("\0") if i == 0: - raise OSError("cannot find subfile") + msg = "cannot find subfile" + raise OSError(msg) if i > 0: name = name[:i] @@ -53,13 +55,3 @@ def __init__(self, tarfile, file): # Open region super().__init__(self.fh, self.fh.tell(), size) - - # Context manager support - def __enter__(self): - return self - - def __exit__(self, *args): - self.close() - - def close(self): - self.fh.close() diff --git a/dependencies/WIN/PIL/TgaImagePlugin.py b/dependencies/WIN/PIL/TgaImagePlugin.py index 5e5d52d..90d5b5c 100644 --- a/dependencies/WIN/PIL/TgaImagePlugin.py +++ b/dependencies/WIN/PIL/TgaImagePlugin.py @@ -15,9 +15,10 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import warnings +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -35,7 +36,7 @@ (3, 1): "1", (3, 8): "L", (3, 16): "LA", - (2, 16): "BGR;5", + (2, 16): "BGRA;15Z", (2, 24): "BGR", (2, 32): "BGRA", } @@ -46,13 +47,13 @@ class TgaImageFile(ImageFile.ImageFile): - format = "TGA" format_description = "Targa" - def _open(self): - + def _open(self) -> None: # process header + assert self.fp is not None + s = self.fp.read(18) id_len = s[0] @@ -73,32 +74,34 @@ def _open(self): or self.size[1] <= 0 or depth not in (1, 8, 16, 24, 32) ): - raise SyntaxError("not a TGA file") + msg = "not a TGA file" + raise SyntaxError(msg) # image mode if imagetype in (3, 11): - self.mode = "L" + self._mode = "L" if depth == 1: - self.mode = "1" # ??? + self._mode = "1" # ??? elif depth == 16: - self.mode = "LA" + self._mode = "LA" elif imagetype in (1, 9): - self.mode = "P" + self._mode = "P" if colormaptype else "L" elif imagetype in (2, 10): - self.mode = "RGB" - if depth == 32: - self.mode = "RGBA" + self._mode = "RGB" if depth == 24 else "RGBA" else: - raise SyntaxError("unknown TGA mode") + msg = "unknown TGA mode" + raise SyntaxError(msg) # orientation orientation = flags & 0x30 - if orientation == 0x20: + self._flip_horizontally = orientation in [0x10, 0x30] + if orientation in [0x20, 0x30]: orientation = 1 - elif not orientation: + elif orientation in [0, 0x10]: orientation = -1 else: - raise SyntaxError("unknown TGA orientation") + msg = "unknown TGA orientation" + raise SyntaxError(msg) self.info["orientation"] = orientation @@ -113,16 +116,20 @@ def _open(self): start, size, mapdepth = i16(s, 3), i16(s, 5), s[7] if mapdepth == 16: self.palette = ImagePalette.raw( - "BGR;15", b"\0" * 2 * start + self.fp.read(2 * size) + "BGRA;15Z", bytes(2 * start) + self.fp.read(2 * size) ) + self.palette.mode = "RGBA" elif mapdepth == 24: self.palette = ImagePalette.raw( - "BGR", b"\0" * 3 * start + self.fp.read(3 * size) + "BGR", bytes(3 * start) + self.fp.read(3 * size) ) elif mapdepth == 32: self.palette = ImagePalette.raw( - "BGRA", b"\0" * 4 * start + self.fp.read(4 * size) + "BGRA", bytes(4 * start) + self.fp.read(4 * size) ) + else: + msg = "unknown TGA map depth" + raise SyntaxError(msg) # setup tile descriptor try: @@ -130,7 +137,7 @@ def _open(self): if imagetype & 8: # compressed self.tile = [ - ( + ImageFile._Tile( "tga_rle", (0, 0) + self.size, self.fp.tell(), @@ -139,7 +146,7 @@ def _open(self): ] else: self.tile = [ - ( + ImageFile._Tile( "raw", (0, 0) + self.size, self.fp.tell(), @@ -149,6 +156,10 @@ def _open(self): except KeyError: pass # cannot decode + def load_end(self) -> None: + if self._flip_horizontally: + self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + # # -------------------------------------------------------------------- @@ -165,12 +176,12 @@ def _open(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TGA") from e + msg = f"cannot write mode {im.mode} as TGA" + raise OSError(msg) from e if "rle" in im.encoderinfo: rle = im.encoderinfo["rle"] @@ -188,9 +199,10 @@ def _save(im, fp, filename): warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: - colormapfirst, colormaplength, colormapentry = 0, 256, 24 + palette = im.im.getpalette("RGB", "BGR") + colormaplength, colormapentry = len(palette) // 3, 24 else: - colormapfirst, colormaplength, colormapentry = 0, 0, 0 + colormaplength, colormapentry = 0, 0 if im.mode in ("LA", "RGBA"): flags = 8 @@ -205,7 +217,7 @@ def _save(im, fp, filename): o8(id_len) + o8(colormaptype) + o8(imagetype) - + o16(colormapfirst) + + o16(0) # colormapfirst + o16(colormaplength) + o8(colormapentry) + o16(0) @@ -220,15 +232,19 @@ def _save(im, fp, filename): fp.write(id_section) if colormaptype: - fp.write(im.im.getpalette("RGB", "BGR")) + fp.write(palette) if rle: ImageFile._save( - im, fp, [("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))] + im, + fp, + [ImageFile._Tile("tga_rle", (0, 0) + im.size, 0, (rawmode, orientation))], ) else: ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, orientation))], ) # write targa version 2 footer diff --git a/dependencies/WIN/PIL/TiffImagePlugin.py b/dependencies/WIN/PIL/TiffImagePlugin.py index 6b311bb..61eb152 100644 --- a/dependencies/WIN/PIL/TiffImagePlugin.py +++ b/dependencies/WIN/PIL/TiffImagePlugin.py @@ -38,26 +38,37 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations + import io import itertools import logging +import math import os import struct import warnings -from collections.abc import MutableMapping +from collections.abc import Iterator, MutableMapping from fractions import Fraction from numbers import Number, Rational +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn, cast -from . import Image, ImageFile, ImageOps, ImagePalette, TiffTags +from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags +from ._binary import i16be as i16 +from ._binary import i32be as i32 from ._binary import o8 +from ._deprecate import deprecate +from ._typing import StrOrBytesPath +from ._util import is_path from .TiffTags import TYPES +if TYPE_CHECKING: + from ._typing import Buffer, IntegralLike + logger = logging.getLogger(__name__) # Set these to true to force use of libtiff for reading or writing. READ_LIBTIFF = False WRITE_LIBTIFF = False -IFD_LEGACY_API = True STRIP_SIZE = 65536 II = b"II" # little-endian (Intel style) @@ -68,6 +79,7 @@ # Read TIFF files # a few tag names, just to make the code below a bit more readable +OSUBFILETYPE = 255 IMAGEWIDTH = 256 IMAGELENGTH = 257 BITSPERSAMPLE = 258 @@ -89,7 +101,10 @@ ARTIST = 315 PREDICTOR = 317 COLORMAP = 320 +TILEWIDTH = 322 +TILELENGTH = 323 TILEOFFSETS = 324 +TILEBYTECOUNTS = 325 SUBIFD = 330 EXTRASAMPLES = 338 SAMPLEFORMAT = 339 @@ -164,11 +179,15 @@ (MM, 0, (1,), 2, (8,), ()): ("L", "L;IR"), (II, 1, (1,), 1, (8,), ()): ("L", "L"), (MM, 1, (1,), 1, (8,), ()): ("L", "L"), + (II, 1, (2,), 1, (8,), ()): ("L", "L"), + (MM, 1, (2,), 1, (8,), ()): ("L", "L"), (II, 1, (1,), 2, (8,), ()): ("L", "L;R"), (MM, 1, (1,), 2, (8,), ()): ("L", "L;R"), (II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"), + (II, 0, (1,), 1, (16,), ()): ("I;16", "I;16"), (II, 1, (1,), 1, (16,), ()): ("I;16", "I;16"), (MM, 1, (1,), 1, (16,), ()): ("I;16B", "I;16B"), + (II, 1, (1,), 2, (16,), ()): ("I;16", "I;16R"), (II, 1, (2,), 1, (16,), ()): ("I", "I;16S"), (MM, 1, (2,), 1, (16,), ()): ("I", "I;16BS"), (II, 0, (3,), 1, (32,), ()): ("F", "F;32F"), @@ -186,12 +205,12 @@ (MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), (II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples (MM, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples - (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), (II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), @@ -210,8 +229,8 @@ (MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"), - (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16L"), - (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"), @@ -230,6 +249,7 @@ (MM, 3, (1,), 2, (4,), ()): ("P", "P;4R"), (II, 3, (1,), 1, (8,), ()): ("P", "P"), (MM, 3, (1,), 1, (8,), ()): ("P", "P"), + (II, 3, (1,), 1, (8, 8), (0,)): ("P", "PX"), (II, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), (MM, 3, (1,), 1, (8, 8), (2,)): ("PA", "PA"), (II, 3, (1,), 2, (8,), ()): ("P", "P;R"), @@ -241,6 +261,9 @@ (II, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (MM, 5, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0)): ("CMYK", "CMYKXX"), (II, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16L"), + (MM, 5, (1,), 1, (16, 16, 16, 16), ()): ("CMYK", "CMYK;16B"), + (II, 6, (1,), 1, (8,), ()): ("L", "L"), + (MM, 6, (1,), 1, (8,), ()): ("L", "L"), # JPEG compressed images handled by LibTiff and auto-converted to RGBX # Minimal Baseline TIFF requires YCbCr images to have 3 SamplesPerPixel (II, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"), @@ -249,34 +272,45 @@ (MM, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"), } +MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO) + PREFIXES = [ b"MM\x00\x2A", # Valid TIFF header with big-endian byte order b"II\x2A\x00", # Valid TIFF header with little-endian byte order b"MM\x2A\x00", # Invalid TIFF header, assume big-endian b"II\x00\x2A", # Invalid TIFF header, assume little-endian + b"MM\x00\x2B", # BigTIFF with big-endian byte order + b"II\x2B\x00", # BigTIFF with little-endian byte order ] +if not getattr(Image.core, "libtiff_support_custom_tags", True): + deprecate("Support for LibTIFF earlier than version 4", 12) -def _accept(prefix): + +def _accept(prefix: bytes) -> bool: return prefix[:4] in PREFIXES -def _limit_rational(val, max_val): +def _limit_rational( + val: float | Fraction | IFDRational, max_val: int +) -> tuple[IntegralLike, IntegralLike]: inv = abs(val) > 1 n_d = IFDRational(1 / val if inv else val).limit_rational(max_val) return n_d[::-1] if inv else n_d -def _limit_signed_rational(val, max_val, min_val): +def _limit_signed_rational( + val: IFDRational, max_val: int, min_val: int +) -> tuple[IntegralLike, IntegralLike]: frac = Fraction(val) - n_d = frac.numerator, frac.denominator + n_d: tuple[IntegralLike, IntegralLike] = frac.numerator, frac.denominator - if min(n_d) < min_val: + if min(float(i) for i in n_d) < min_val: n_d = _limit_rational(val, abs(min_val)) - if max(n_d) > max_val: - val = Fraction(*n_d) - n_d = _limit_rational(val, max_val) + n_d_float = tuple(float(i) for i in n_d) + if max(n_d_float) > max_val: + n_d = _limit_rational(n_d_float[0] / n_d_float[1], max_val) return n_d @@ -288,6 +322,15 @@ def _limit_signed_rational(val, max_val, min_val): _write_dispatch = {} +def _delegate(op: str) -> Any: + def delegate( + self: IFDRational, *args: tuple[float, ...] + ) -> bool | float | Fraction: + return getattr(self._val, op)(*args) + + return delegate + + class IFDRational(Rational): """Implements a rational class where 0/0 is a legal value to match the in the wild use of exif rationals. @@ -302,12 +345,15 @@ class IFDRational(Rational): __slots__ = ("_numerator", "_denominator", "_val") - def __init__(self, value, denominator=1): + def __init__( + self, value: float | Fraction | IFDRational, denominator: int = 1 + ) -> None: """ :param value: either an integer numerator, a float/rational/other number, or an IFDRational :param denominator: Optional integer denominator """ + self._val: Fraction | float if isinstance(value, IFDRational): self._numerator = value.numerator self._denominator = value.denominator @@ -318,25 +364,30 @@ def __init__(self, value, denominator=1): self._numerator = value.numerator self._denominator = value.denominator else: - self._numerator = value + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, value) + else: + self._numerator = value self._denominator = denominator if denominator == 0: self._val = float("nan") elif denominator == 1: self._val = Fraction(value) + elif int(value) == value: + self._val = Fraction(int(value), denominator) else: - self._val = Fraction(value, denominator) + self._val = Fraction(value / denominator) @property - def numerator(a): - return a._numerator + def numerator(self) -> IntegralLike: + return self._numerator @property - def denominator(a): - return a._denominator + def denominator(self) -> int: + return self._denominator - def limit_rational(self, max_denominator): + def limit_rational(self, max_denominator: int) -> tuple[IntegralLike, int]: """ :param max_denominator: Integer, the maximum denominator value @@ -344,18 +395,19 @@ def limit_rational(self, max_denominator): """ if self.denominator == 0: - return (self.numerator, self.denominator) + return self.numerator, self.denominator + assert isinstance(self._val, Fraction) f = self._val.limit_denominator(max_denominator) - return (f.numerator, f.denominator) + return f.numerator, f.denominator - def __repr__(self): + def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self): + def __hash__(self) -> int: return self._val.__hash__() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: val = self._val if isinstance(other, IFDRational): other = other._val @@ -363,22 +415,21 @@ def __eq__(self, other): val = float(val) return val == other - def __getstate__(self): + def __getstate__(self) -> list[float | Fraction | IntegralLike]: return [self._val, self._numerator, self._denominator] - def __setstate__(self, state): + def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: IFDRational.__init__(self, 0) _val, _numerator, _denominator = state + assert isinstance(_val, (float, Fraction)) self._val = _val - self._numerator = _numerator + if TYPE_CHECKING: + self._numerator = cast(IntegralLike, _numerator) + else: + self._numerator = _numerator + assert isinstance(_denominator, int) self._denominator = _denominator - def _delegate(op): - def delegate(self, *args): - return getattr(self._val, op)(*args) - - return delegate - """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', 'truediv', 'rtruediv', 'floordiv', 'rfloordiv', 'mod','rmod', 'pow','rpow', 'pos', 'neg', @@ -413,9 +464,59 @@ def delegate(self, *args): __ceil__ = _delegate("__ceil__") __floor__ = _delegate("__floor__") __round__ = _delegate("__round__") + # Python >= 3.11 + if hasattr(Fraction, "__int__"): + __int__ = _delegate("__int__") + + +_LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] + + +def _register_loader(idx: int, size: int) -> Callable[[_LoaderFunc], _LoaderFunc]: + def decorator(func: _LoaderFunc) -> _LoaderFunc: + from .TiffTags import TYPES + + if func.__name__.startswith("load_"): + TYPES[idx] = func.__name__[5:].replace("_", " ") + _load_dispatch[idx] = size, func # noqa: F821 + return func + return decorator -class ImageFileDirectory_v2(MutableMapping): + +def _register_writer(idx: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + _write_dispatch[idx] = func # noqa: F821 + return func + + return decorator + + +def _register_basic(idx_fmt_name: tuple[int, str, str]) -> None: + from .TiffTags import TYPES + + idx, fmt, name = idx_fmt_name + TYPES[idx] = name + size = struct.calcsize(f"={fmt}") + + def basic_handler( + self: ImageFileDirectory_v2, data: bytes, legacy_api: bool = True + ) -> tuple[Any, ...]: + return self._unpack(f"{len(data) // size}{fmt}", data) + + _load_dispatch[idx] = size, basic_handler # noqa: F821 + _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 + b"".join(self._pack(fmt, value) for value in values) + ) + + +if TYPE_CHECKING: + _IFDv2Base = MutableMapping[int, Any] +else: + _IFDv2Base = MutableMapping + + +class ImageFileDirectory_v2(_IFDv2Base): """This class represents a TIFF tag directory. To speed things up, we don't decode tags unless they're asked for. @@ -476,7 +577,15 @@ class ImageFileDirectory_v2(MutableMapping): """ - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): + _load_dispatch: dict[int, tuple[int, _LoaderFunc]] = {} + _write_dispatch: dict[int, Callable[..., Any]] = {} + + def __init__( + self, + ifh: bytes = b"II\x2A\x00\x00\x00\x00\x00", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -487,42 +596,53 @@ def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): endianness. :param prefix: Override the endianness of the file. """ - if ifh[:4] not in PREFIXES: - raise SyntaxError(f"not a TIFF file (header {repr(ifh)} not valid)") + if not _accept(ifh): + msg = f"not a TIFF file (header {repr(ifh)} not valid)" + raise SyntaxError(msg) self._prefix = prefix if prefix is not None else ifh[:2] if self._prefix == MM: self._endian = ">" elif self._prefix == II: self._endian = "<" else: - raise SyntaxError("not a TIFF IFD") + msg = "not a TIFF IFD" + raise SyntaxError(msg) + self._bigtiff = ifh[2] == 43 self.group = group - self.tagtype = {} + self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() - (self.next,) = self._unpack("L", ifh[4:]) + self.next = ( + self._unpack("Q", ifh[8:])[0] + if self._bigtiff + else self._unpack("L", ifh[4:])[0] + ) self._legacy_api = False prefix = property(lambda self: self._prefix) offset = property(lambda self: self._offset) - legacy_api = property(lambda self: self._legacy_api) - @legacy_api.setter - def legacy_api(self, value): - raise Exception("Not allowing setting of legacy api") + @property + def legacy_api(self) -> bool: + return self._legacy_api - def reset(self): - self._tags_v1 = {} # will remain empty if legacy_api is false - self._tags_v2 = {} # main tag storage - self._tagdata = {} + @legacy_api.setter + def legacy_api(self, value: bool) -> NoReturn: + msg = "Not allowing setting of legacy api" + raise Exception(msg) + + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None - self._offset = None + self._offset: int | None = None - def __str__(self): + def __str__(self) -> str: return str(dict(self)) - def named(self): + def named(self) -> dict[str, Any]: """ :returns: dict of name|key: value @@ -533,10 +653,10 @@ def named(self): for code, value in self.items() } - def __len__(self): + def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v2)) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v2: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -547,13 +667,13 @@ def __getitem__(self, tag): val = (val,) return val - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v2 or tag in self._tagdata - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: self._setitem(tag, value, self.legacy_api) - def _setitem(self, tag, value, legacy_api): + def _setitem(self, tag: int, value: Any, legacy_api: bool) -> None: basetypes = (Number, bytes, str) info = TiffTags.lookup(tag, self.group) @@ -565,22 +685,33 @@ def _setitem(self, tag, value, legacy_api): else: self.tagtype[tag] = TiffTags.UNDEFINED if all(isinstance(v, IFDRational) for v in values): - self.tagtype[tag] = ( - TiffTags.RATIONAL - if all(v >= 0 for v in values) - else TiffTags.SIGNED_RATIONAL - ) + for v in values: + assert isinstance(v, IFDRational) + if v < 0: + self.tagtype[tag] = TiffTags.SIGNED_RATIONAL + break + else: + self.tagtype[tag] = TiffTags.RATIONAL elif all(isinstance(v, int) for v in values): - if all(0 <= v < 2 ** 16 for v in values): + short = True + signed_short = True + long = True + for v in values: + assert isinstance(v, int) + if short and not (0 <= v < 2**16): + short = False + if signed_short and not (-(2**15) < v < 2**15): + signed_short = False + if long and v < 0: + long = False + if short: self.tagtype[tag] = TiffTags.SHORT - elif all(-(2 ** 15) < v < 2 ** 15 for v in values): + elif signed_short: self.tagtype[tag] = TiffTags.SIGNED_SHORT + elif long: + self.tagtype[tag] = TiffTags.LONG else: - self.tagtype[tag] = ( - TiffTags.LONG - if all(v >= 0 for v in values) - else TiffTags.SIGNED_LONG - ) + self.tagtype[tag] = TiffTags.SIGNED_LONG elif all(isinstance(v, float) for v in values): self.tagtype[tag] = TiffTags.DOUBLE elif all(isinstance(v, str) for v in values): @@ -598,7 +729,10 @@ def _setitem(self, tag, value, legacy_api): is_ifd = self.tagtype[tag] == TiffTags.LONG and isinstance(values, dict) if not is_ifd: - values = tuple(info.cvt_enum(value) for value in values) + values = tuple( + info.cvt_enum(value) if isinstance(value, str) else value + for value in values + ) dest = self._tags_v1 if legacy_api else self._tags_v2 @@ -633,54 +767,20 @@ def _setitem(self, tag, value, legacy_api): # Unspec'd, and length > 1 dest[tag] = values - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: self._tags_v2.pop(tag, None) self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v2)) - def _unpack(self, fmt, data): + def _unpack(self, fmt: str, data: bytes) -> tuple[Any, ...]: return struct.unpack(self._endian + fmt, data) - def _pack(self, fmt, *values): + def _pack(self, fmt: str, *values: Any) -> bytes: return struct.pack(self._endian + fmt, *values) - def _register_loader(idx, size): - def decorator(func): - from .TiffTags import TYPES - - if func.__name__.startswith("load_"): - TYPES[idx] = func.__name__[5:].replace("_", " ") - _load_dispatch[idx] = size, func # noqa: F821 - return func - - return decorator - - def _register_writer(idx): - def decorator(func): - _write_dispatch[idx] = func # noqa: F821 - return func - - return decorator - - def _register_basic(idx_fmt_name): - from .TiffTags import TYPES - - idx, fmt, name = idx_fmt_name - TYPES[idx] = name - size = struct.calcsize("=" + fmt) - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack("{}{}".format(len(data) // size, fmt), data) - ), - ) - _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 - b"".join(self._pack(fmt, value) for value in values) - ) - list( map( _register_basic, @@ -693,85 +793,111 @@ def _register_basic(idx_fmt_name): (TiffTags.FLOAT, "f", "float"), (TiffTags.DOUBLE, "d", "double"), (TiffTags.IFD, "L", "long"), + (TiffTags.LONG8, "Q", "long8"), ], ) ) @_register_loader(1, 1) # Basic type, except for the legacy API. - def load_byte(self, data, legacy_api=True): + def load_byte(self, data: bytes, legacy_api: bool = True) -> bytes: return data @_register_writer(1) # Basic type, except for the legacy API. - def write_byte(self, data): + def write_byte(self, data: bytes | int | IFDRational) -> bytes: + if isinstance(data, IFDRational): + data = int(data) + if isinstance(data, int): + data = bytes((data,)) return data @_register_loader(2, 1) - def load_string(self, data, legacy_api=True): + def load_string(self, data: bytes, legacy_api: bool = True) -> str: if data.endswith(b"\0"): data = data[:-1] return data.decode("latin-1", "replace") @_register_writer(2) - def write_string(self, value): + def write_string(self, value: str | bytes | int) -> bytes: # remerge of https://github.com/python-pillow/Pillow/pull/1416 - return b"" + value.encode("ascii", "replace") + b"\0" + if isinstance(value, int): + value = str(value) + if not isinstance(value, bytes): + value = value.encode("ascii", "replace") + return value + b"\0" @_register_loader(5, 8) - def load_rational(self, data, legacy_api=True): - vals = self._unpack("{}L".format(len(data) // 4), data) + def load_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}L", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(5) - def write_rational(self, *values): + def write_rational(self, *values: IFDRational) -> bytes: return b"".join( - self._pack("2L", *_limit_rational(frac, 2 ** 32 - 1)) for frac in values + self._pack("2L", *_limit_rational(frac, 2**32 - 1)) for frac in values ) @_register_loader(7, 1) - def load_undefined(self, data, legacy_api=True): + def load_undefined(self, data: bytes, legacy_api: bool = True) -> bytes: return data @_register_writer(7) - def write_undefined(self, value): + def write_undefined(self, value: bytes | int | IFDRational) -> bytes: + if isinstance(value, IFDRational): + value = int(value) + if isinstance(value, int): + value = str(value).encode("ascii", "replace") return value @_register_loader(10, 8) - def load_signed_rational(self, data, legacy_api=True): - vals = self._unpack("{}l".format(len(data) // 4), data) + def load_signed_rational( + self, data: bytes, legacy_api: bool = True + ) -> tuple[tuple[int, int] | IFDRational, ...]: + vals = self._unpack(f"{len(data) // 4}l", data) - def combine(a, b): + def combine(a: int, b: int) -> tuple[int, int] | IFDRational: return (a, b) if legacy_api else IFDRational(a, b) return tuple(combine(num, denom) for num, denom in zip(vals[::2], vals[1::2])) @_register_writer(10) - def write_signed_rational(self, *values): + def write_signed_rational(self, *values: IFDRational) -> bytes: return b"".join( - self._pack("2l", *_limit_signed_rational(frac, 2 ** 31 - 1, -(2 ** 31))) + self._pack("2l", *_limit_signed_rational(frac, 2**31 - 1, -(2**31))) for frac in values ) - def _ensure_read(self, fp, size): + def _ensure_read(self, fp: IO[bytes], size: int) -> bytes: ret = fp.read(size) if len(ret) != size: - raise OSError( + msg = ( "Corrupt EXIF data. " f"Expecting to read {size} bytes but only got {len(ret)}. " ) + raise OSError(msg) return ret - def load(self, fp): - + def load(self, fp: IO[bytes]) -> None: self.reset() self._offset = fp.tell() try: - for i in range(self._unpack("H", self._ensure_read(fp, 2))[0]): - tag, typ, count, data = self._unpack("HHL4s", self._ensure_read(fp, 12)) + tag_count = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("H", self._ensure_read(fp, 2)) + )[0] + for i in range(tag_count): + tag, typ, count, data = ( + self._unpack("HHQ8s", self._ensure_read(fp, 20)) + if self._bigtiff + else self._unpack("HHL4s", self._ensure_read(fp, 12)) + ) tagname = TiffTags.lookup(tag, self.group).name typname = TYPES.get(typ, "unknown") @@ -780,12 +906,12 @@ def load(self, fp): try: unit_size, handler = self._load_dispatch[typ] except KeyError: - logger.debug(msg + f" - unsupported type {typ}") + logger.debug("%s - unsupported type %s", msg, typ) continue # ignore unsupported type size = count * unit_size - if size > 4: + if size > (8 if self._bigtiff else 4): here = fp.tell() - (offset,) = self._unpack("L", data) + (offset,) = self._unpack("Q" if self._bigtiff else "L", data) msg += f" Tag Location: {here} - Data Location: {offset}" fp.seek(offset) data = ImageFile._safe_read(fp, size) @@ -809,38 +935,48 @@ def load(self, fp): self._tagdata[tag] = data self.tagtype[tag] = typ - msg += " - value: " + ( - "" % size if size > 32 else repr(data) - ) + msg += " - value: " + msg += f"" if size > 32 else repr(data) + logger.debug(msg) - (self.next,) = self._unpack("L", self._ensure_read(fp, 4)) + (self.next,) = ( + self._unpack("Q", self._ensure_read(fp, 8)) + if self._bigtiff + else self._unpack("L", self._ensure_read(fp, 4)) + ) except OSError as msg: warnings.warn(str(msg)) return - def tobytes(self, offset=0): + def _get_ifh(self): + ifh = self._prefix + self._pack("H", 43 if self._bigtiff else 42) + if self._bigtiff: + ifh += self._pack("HH", 8, 0) + ifh += self._pack("Q", 16) if self._bigtiff else self._pack("L", 8) + + return ifh + + def tobytes(self, offset: int = 0) -> bytes: # FIXME What about tagdata? - result = self._pack("H", len(self._tags_v2)) + result = self._pack("Q" if self._bigtiff else "H", len(self._tags_v2)) - entries = [] - offset = offset + len(result) + len(self._tags_v2) * 12 + 4 + entries: list[tuple[int, int, int, bytes, bytes]] = [] + offset += len(result) + len(self._tags_v2) * (20 if self._bigtiff else 12) + 4 stripoffsets = None # pass 1: convert tags to binary format # always write tags in ascending order + fmt = "Q" if self._bigtiff else "L" + fmt_size = 8 if self._bigtiff else 4 for tag, value in sorted(self._tags_v2.items()): if tag == STRIPOFFSETS: stripoffsets = len(entries) - typ = self.tagtype.get(tag) - logger.debug(f"Tag {tag}, Type: {typ}, Value: {repr(value)}") + typ = self.tagtype[tag] + logger.debug("Tag %s, Type: %s, Value: %s", tag, typ, repr(value)) is_ifd = typ == TiffTags.LONG and isinstance(value, dict) if is_ifd: - if self._endian == "<": - ifh = b"II\x2A\x00\x08\x00\x00\x00" - else: - ifh = b"MM\x00\x2A\x00\x00\x00\x08" - ifd = ImageFileDirectory_v2(ifh, group=tag) + ifd = ImageFileDirectory_v2(self._get_ifh(), group=tag) values = self._tags_v2[tag] for ifd_tag, ifd_value in values.items(): ifd[ifd_tag] = ifd_value @@ -851,10 +987,8 @@ def tobytes(self, offset=0): tagname = TiffTags.lookup(tag, self.group).name typname = "ifd" if is_ifd else TYPES.get(typ, "unknown") - msg = f"save: {tagname} ({tag}) - type: {typname} ({typ})" - msg += " - value: " + ( - "" % len(data) if len(data) >= 16 else str(values) - ) + msg = f"save: {tagname} ({tag}) - type: {typname} ({typ}) - value: " + msg += f"" if len(data) >= 16 else str(values) logger.debug(msg) # count is sum of lengths for string and arbitrary data @@ -865,24 +999,29 @@ def tobytes(self, offset=0): else: count = len(values) # figure out if data fits into the entry - if len(data) <= 4: - entries.append((tag, typ, count, data.ljust(4, b"\0"), b"")) + if len(data) <= fmt_size: + entries.append((tag, typ, count, data.ljust(fmt_size, b"\0"), b"")) else: - entries.append((tag, typ, count, self._pack("L", offset), data)) + entries.append((tag, typ, count, self._pack(fmt, offset), data)) offset += (len(data) + 1) // 2 * 2 # pad to word # update strip offset data to point beyond auxiliary data if stripoffsets is not None: tag, typ, count, value, data = entries[stripoffsets] if data: - raise NotImplementedError("multistrip support not yet implemented") - value = self._pack("L", self._unpack("L", value)[0] + offset) + size, handler = self._load_dispatch[typ] + values = [val + offset for val in handler(self, data, self.legacy_api)] + data = self._write_dispatch[typ](self, *values) + else: + value = self._pack(fmt, self._unpack(fmt, value)[0] + offset) entries[stripoffsets] = tag, typ, count, value, data # pass 2: write entries to file for tag, typ, count, value, data in entries: - logger.debug(f"{tag} {typ} {count} {repr(value)} {repr(data)}") - result += self._pack("HHL4s", tag, typ, count, value) + logger.debug("%s %s %s %s %s", tag, typ, count, repr(value), repr(data)) + result += self._pack( + "HHQ8s" if self._bigtiff else "HHL4s", tag, typ, count, value + ) # -- overwrite here for multi-page -- result += b"\0\0\0\0" # end of entries @@ -895,11 +1034,9 @@ def tobytes(self, offset=0): return result - def save(self, fp): - + def save(self, fp: IO[bytes]) -> int: if fp.tell() == 0: # skip TIFF header on subsequent pages - # tiff header -- PIL always starts the first IFD at offset 8 - fp.write(self._prefix + self._pack("HL", 42, 8)) + fp.write(self._get_ifh()) offset = fp.tell() result = self.tobytes(offset) @@ -911,8 +1048,8 @@ def save(self, fp): ImageFileDirectory_v2._write_dispatch = _write_dispatch for idx, name in TYPES.items(): name = name.replace(" ", "_") - setattr(ImageFileDirectory_v2, "load_" + name, _load_dispatch[idx][1]) - setattr(ImageFileDirectory_v2, "write_" + name, _write_dispatch[idx]) + setattr(ImageFileDirectory_v2, f"load_{name}", _load_dispatch[idx][1]) + setattr(ImageFileDirectory_v2, f"write_{name}", _write_dispatch[idx]) del _load_dispatch, _write_dispatch, idx, name @@ -936,7 +1073,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): .. deprecated:: 3.0.0 """ - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._legacy_api = True @@ -944,11 +1081,11 @@ def __init__(self, *args, **kwargs): tagdata = property(lambda self: self._tagdata) # defined in ImageFileDirectory_v2 - tagtype: dict + tagtype: dict[int, int] """Dictionary of tag types""" @classmethod - def from_v2(cls, original): + def from_v2(cls, original: ImageFileDirectory_v2) -> ImageFileDirectory_v1: """Returns an :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` instance with the same data as is contained in the original @@ -965,7 +1102,7 @@ def from_v2(cls, original): ifd.next = original.next # an indicator for multipage tiffs return ifd - def to_v2(self): + def to_v2(self) -> ImageFileDirectory_v2: """Returns an :py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` instance with the same data as is contained in the original @@ -982,20 +1119,20 @@ def to_v2(self): ifd._tags_v2 = dict(self._tags_v2) return ifd - def __contains__(self, tag): + def __contains__(self, tag: object) -> bool: return tag in self._tags_v1 or tag in self._tagdata - def __len__(self): + def __len__(self) -> int: return len(set(self._tagdata) | set(self._tags_v1)) - def __iter__(self): + def __iter__(self) -> Iterator[int]: return iter(set(self._tagdata) | set(self._tags_v1)) - def __setitem__(self, tag, value): + def __setitem__(self, tag: int, value: Any) -> None: for legacy_api in (False, True): self._setitem(tag, value, legacy_api) - def __getitem__(self, tag): + def __getitem__(self, tag: int) -> Any: if tag not in self._tags_v1: # unpack on the fly data = self._tagdata[tag] typ = self.tagtype[tag] @@ -1008,7 +1145,7 @@ def __getitem__(self, tag): return val -# undone -- switch this pointer when IFD_LEGACY_API == False +# undone -- switch this pointer ImageFileDirectory = ImageFileDirectory_v1 @@ -1017,83 +1154,90 @@ def __getitem__(self, tag): class TiffImageFile(ImageFile.ImageFile): - format = "TIFF" format_description = "Adobe TIFF" _close_exclusive_fp_after_loading = False - def __init__(self, fp=None, filename=None): - self.tag_v2 = None + def __init__( + self, + fp: StrOrBytesPath | IO[bytes], + filename: str | bytes | None = None, + ) -> None: + self.tag_v2: ImageFileDirectory_v2 """ Image file directory (tag dictionary) """ - self.tag = None + self.tag: ImageFileDirectory_v1 """ Legacy tag entries """ super().__init__(fp, filename) - def _open(self): + def _open(self) -> None: """Open the first image in a TIFF file""" # Header ifh = self.fp.read(8) + if ifh[2] == 43: + ifh += self.fp.read(8) self.tag_v2 = ImageFileDirectory_v2(ifh) - # legacy IFD entries will be filled in later - self.ifd = None - # setup frame pointers self.__first = self.__next = self.tag_v2.next self.__frame = -1 - self.__fp = self.fp - self._frame_pos = [] - self._n_frames = None + self._fp = self.fp + self._frame_pos: list[int] = [] + self._n_frames: int | None = None logger.debug("*** TiffImageFile._open ***") - logger.debug(f"- __first: {self.__first}") - logger.debug(f"- ifh: {repr(ifh)}") # Use repr to avoid str(bytes) + logger.debug("- __first: %s", self.__first) + logger.debug("- ifh: %s", repr(ifh)) # Use repr to avoid str(bytes) # and load the first frame self._seek(0) @property - def n_frames(self): - if self._n_frames is None: + def n_frames(self) -> int: + current_n_frames = self._n_frames + if current_n_frames is None: current = self.tell() self._seek(len(self._frame_pos)) while self._n_frames is None: self._seek(self.tell() + 1) self.seek(current) + assert self._n_frames is not None return self._n_frames - def seek(self, frame): + def seek(self, frame: int) -> None: """Select a given frame as current image""" if not self._seek_check(frame): return self._seek(frame) - # Create a new core image object on second and - # subsequent frames in the image. Image may be - # different size/mode. - Image._decompression_bomb_check(self.size) - self.im = Image.core.new(self.mode, self.size) - - def _seek(self, frame): - self.fp = self.__fp + if self._im is not None and ( + self.im.size != self._tile_size or self.im.mode != self.mode + ): + # The core image will no longer be used + self._im = None - # reset buffered io handle in case fp - # was passed to libtiff, invalidating the buffer - self.fp.tell() + def _seek(self, frame: int) -> None: + self.fp = self._fp while len(self._frame_pos) <= frame: if not self.__next: - raise EOFError("no more images in TIFF file") + msg = "no more images in TIFF file" + raise EOFError(msg) logger.debug( - f"Seeking to frame {frame}, on frame {self.__frame}, " - f"__next {self.__next}, location: {self.fp.tell()}" + "Seeking to frame %s, on frame %s, __next %s, location: %s", + frame, + self.__frame, + self.__next, + self.fp.tell(), ) + if self.__next >= 2**63: + msg = "Unable to seek to frame" + raise ValueError(msg) self.fp.seek(self.__next) self._frame_pos.append(self.__next) - logger.debug("Loading tags, location: %s" % self.fp.tell()) + logger.debug("Loading tags, location: %s", self.fp.tell()) self.tag_v2.load(self.fp) if self.tag_v2.next in self._frame_pos: # This IFD has already been processed @@ -1108,60 +1252,70 @@ def _seek(self, frame): self.__frame += 1 self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + self.info["xmp"] = self.tag_v2[XMP] + elif "xmp" in self.info: + del self.info["xmp"] + self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) self.__frame = frame self._setup() - def tell(self): + def tell(self) -> int: """Return the current frame number""" return self.__frame - def getxmp(self): + def get_photoshop_blocks(self) -> dict[int, dict[str, bytes]]: """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - :returns: XMP tags in a dictionary. - """ - return self._getxmp(self.tag_v2[700]) if 700 in self.tag_v2 else {} + Returns a dictionary of Photoshop "Image Resource Blocks". + The keys are the image resource ID. For more information, see + https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_pgfId-1037727 - def load(self): + :returns: Photoshop "Image Resource Blocks" in a dictionary. + """ + blocks = {} + val = self.tag_v2.get(ExifTags.Base.ImageResources) + if val: + while val[:4] == b"8BIM": + id = i16(val[4:6]) + n = math.ceil((val[6] + 1) / 2) * 2 + size = i32(val[6 + n : 10 + n]) + data = val[10 + n : 10 + n + size] + blocks[id] = {"data": data} + + val = val[math.ceil((10 + n + size) / 2) * 2 :] + return blocks + + def load(self) -> Image.core.PixelAccess | None: if self.tile and self.use_load_libtiff: return self._load_libtiff() return super().load() - def load_end(self): - if self._tile_orientation: - method = { - 2: Image.FLIP_LEFT_RIGHT, - 3: Image.ROTATE_180, - 4: Image.FLIP_TOP_BOTTOM, - 5: Image.TRANSPOSE, - 6: Image.ROTATE_270, - 7: Image.TRANSVERSE, - 8: Image.ROTATE_90, - }.get(self._tile_orientation) - if method is not None: - self.im = self.im.transpose(method) - self._size = self.im.size + def load_prepare(self) -> None: + if self._im is None: + Image._decompression_bomb_check(self._tile_size) + self.im = Image.core.new(self.mode, self._tile_size) + ImageFile.ImageFile.load_prepare(self) + def load_end(self) -> None: # allow closing if we're on the first frame, there's no next # This is the ImageFile.load path only, libtiff specific below. if not self.is_animated: self._close_exclusive_fp_after_loading = True - # reset buffered io handle in case fp - # was passed to libtiff, invalidating the buffer - self.fp.tell() - # load IFD data from fp before it is closed exif = self.getexif() - for key in TiffTags.TAGS_V2_GROUPS.keys(): + for key in TiffTags.TAGS_V2_GROUPS: if key not in exif: continue exif.get_ifd(key) - def _load_libtiff(self): + ImageOps.exif_transpose(self, in_place=True) + if ExifTags.Base.Orientation in self.tag_v2: + del self.tag_v2[ExifTags.Base.Orientation] + + def _load_libtiff(self) -> Image.core.PixelAccess | None: """Overload method triggered when we detect a compressed tiff Calls out to libtiff""" @@ -1170,19 +1324,19 @@ def _load_libtiff(self): self.load_prepare() if not len(self.tile) == 1: - raise OSError("Not exactly one tile") + msg = "Not exactly one tile" + raise OSError(msg) # (self._compression, (extents tuple), # 0, (rawmode, self._compression, fp)) extents = self.tile[0][1] - args = list(self.tile[0][3]) + args = self.tile[0][3] # To be nice on memory footprint, if there's a # file descriptor, use that instead of reading # into a string in python. - # libtiff closes the file descriptor, so pass in a dup. try: - fp = hasattr(self.fp, "fileno") and os.dup(self.fp.fileno()) + fp = hasattr(self.fp, "fileno") and self.fp.fileno() # flush the file descriptor, prevents error on pypy 2.4+ # should also eliminate the need for fp.tell # in _seek @@ -1194,15 +1348,17 @@ def _load_libtiff(self): fp = False if fp: - args[2] = fp + assert isinstance(args, tuple) + args_list = list(args) + args_list[2] = fp + args = tuple(args_list) - decoder = Image._getdecoder( - self.mode, "libtiff", tuple(args), self.decoderconfig - ) + decoder = Image._getdecoder(self.mode, "libtiff", args, self.decoderconfig) try: decoder.setimage(self.im, extents) except ValueError as e: - raise OSError("Couldn't set the image") from e + msg = "Couldn't set the image" + raise OSError(msg) from e close_self_fp = self._exclusive_fp and not self.is_animated if hasattr(self.fp, "getvalue"): @@ -1222,8 +1378,17 @@ def _load_libtiff(self): logger.debug("have fileno, calling fileno version of the decoder.") if not close_self_fp: self.fp.seek(0) + # Save and restore the file position, because libtiff will move it + # outside of the Python runtime, and that will confuse + # io.BufferedReader and possible others. + # NOTE: This must use os.lseek(), and not fp.tell()/fp.seek(), + # because the buffer read head already may not equal the actual + # file position, and fp.seek() may just adjust it's internal + # pointer and not actually seek the OS file handle. + pos = os.lseek(fp, 0, os.SEEK_CUR) # 4 bytes, otherwise the trace might error out n, err = decoder.decode(b"fpfp") + os.lseek(fp, pos, os.SEEK_SET) else: # we have something else. logger.debug("don't have fileno or getvalue. just reading") @@ -1236,7 +1401,6 @@ def _load_libtiff(self): self.load_end() - # libtiff closed the fp in a, we need to close self.fp, if possible if close_self_fp: self.fp.close() self.fp = None # might be shared @@ -1246,11 +1410,12 @@ def _load_libtiff(self): return Image.Image.load(self) - def _setup(self): + def _setup(self) -> None: """Setup this image object based on current tags""" if 0xBC01 in self.tag_v2: - raise OSError("Windows Media Photo files not yet supported") + msg = "Windows Media Photo files not yet supported" + raise OSError(msg) # extract relevant tags self._compression = COMPRESSION_INFO[self.tag_v2.get(COMPRESSION, 1)] @@ -1267,27 +1432,39 @@ def _setup(self): fillorder = self.tag_v2.get(FILLORDER, 1) logger.debug("*** Summary ***") - logger.debug(f"- compression: {self._compression}") - logger.debug(f"- photometric_interpretation: {photo}") - logger.debug(f"- planar_configuration: {self._planar_configuration}") - logger.debug(f"- fill_order: {fillorder}") - logger.debug(f"- YCbCr subsampling: {self.tag.get(530)}") + logger.debug("- compression: %s", self._compression) + logger.debug("- photometric_interpretation: %s", photo) + logger.debug("- planar_configuration: %s", self._planar_configuration) + logger.debug("- fill_order: %s", fillorder) + logger.debug("- YCbCr subsampling: %s", self.tag_v2.get(YCBCRSUBSAMPLING)) # size - xsize = int(self.tag_v2.get(IMAGEWIDTH)) - ysize = int(self.tag_v2.get(IMAGELENGTH)) - self._size = xsize, ysize + try: + xsize = self.tag_v2[IMAGEWIDTH] + ysize = self.tag_v2[IMAGELENGTH] + except KeyError as e: + msg = "Missing dimensions" + raise TypeError(msg) from e + if not isinstance(xsize, int) or not isinstance(ysize, int): + msg = "Invalid dimensions" + raise ValueError(msg) + self._tile_size = xsize, ysize + orientation = self.tag_v2.get(ExifTags.Base.Orientation) + if orientation in (5, 6, 7, 8): + self._size = ysize, xsize + else: + self._size = xsize, ysize - logger.debug(f"- size: {self.size}") + logger.debug("- size: %s", self.size) - sampleFormat = self.tag_v2.get(SAMPLEFORMAT, (1,)) - if len(sampleFormat) > 1 and max(sampleFormat) == min(sampleFormat) == 1: + sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,)) + if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1: # SAMPLEFORMAT is properly per band, so an RGB image will # be (1,1,1). But, we don't support per band pixel types, # and anything more than one band is a uint8. So, just # take the first element. Revisit this if adding support # for more exotic images. - sampleFormat = (1,) + sample_format = (1,) bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,)) extra_tuple = self.tag_v2.get(EXTRASAMPLES, ()) @@ -1298,36 +1475,52 @@ def _setup(self): else: bps_count = 1 bps_count += len(extra_tuple) - # Some files have only one value in bps_tuple, - # while should have more. Fix it - if bps_count > len(bps_tuple) and len(bps_tuple) == 1: - bps_tuple = bps_tuple * bps_count - - samplesPerPixel = self.tag_v2.get( + bps_actual_count = len(bps_tuple) + samples_per_pixel = self.tag_v2.get( SAMPLESPERPIXEL, 3 if self._compression == "tiff_jpeg" and photo in (2, 6) else 1, ) - if len(bps_tuple) != samplesPerPixel: - raise SyntaxError("unknown data organization") + + if samples_per_pixel > MAX_SAMPLESPERPIXEL: + # DOS check, samples_per_pixel can be a Long, and we extend the tuple below + logger.error( + "More samples per pixel than can be decoded: %s", samples_per_pixel + ) + msg = "Invalid value for samples per pixel" + raise SyntaxError(msg) + + if samples_per_pixel < bps_actual_count: + # If a file has more values in bps_tuple than expected, + # remove the excess. + bps_tuple = bps_tuple[:samples_per_pixel] + elif samples_per_pixel > bps_actual_count and bps_actual_count == 1: + # If a file has only one value in bps_tuple, when it should have more, + # presume it is the same number of bits for all of the samples. + bps_tuple = bps_tuple * samples_per_pixel + + if len(bps_tuple) != samples_per_pixel: + msg = "unknown data organization" + raise SyntaxError(msg) # mode: check photometric interpretation and bits per pixel key = ( self.tag_v2.prefix, photo, - sampleFormat, + sample_format, fillorder, bps_tuple, extra_tuple, ) - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) try: - self.mode, rawmode = OPEN_INFO[key] + self._mode, rawmode = OPEN_INFO[key] except KeyError as e: logger.debug("- unsupported format") - raise SyntaxError("unknown pixel mode") from e + msg = "unknown pixel mode" + raise SyntaxError(msg) from e - logger.debug(f"- raw mode: {rawmode}") - logger.debug(f"- pil mode: {self.mode}") + logger.debug("- raw mode: %s", rawmode) + logger.debug("- pil mode: %s", self.mode) self.info["compression"] = self._compression @@ -1368,22 +1561,11 @@ def _setup(self): if fillorder == 2: # Replace fillorder with fillorder=1 key = key[:3] + (1,) + key[4:] - logger.debug(f"format key: {key}") + logger.debug("format key: %s", key) # this should always work, since all the # fillorder==2 modes have a corresponding # fillorder=1 mode - self.mode, rawmode = OPEN_INFO[key] - # libtiff always returns the bytes in native order. - # we're expecting image byte order. So, if the rawmode - # contains I;16, we need to convert from native to image - # byte order. - if rawmode == "I;16": - rawmode = "I;16N" - if ";16B" in rawmode: - rawmode = rawmode.replace(";16B", ";16N") - if ";16L" in rawmode: - rawmode = rawmode.replace(";16L", ";16N") - + self._mode, rawmode = OPEN_INFO[key] # YCbCr images with new jpeg compression with pixels in one plane # unpacked straight into RGB values if ( @@ -1392,23 +1574,35 @@ def _setup(self): and self._planar_configuration == 1 ): rawmode = "RGB" + # libtiff always returns the bytes in native order. + # we're expecting image byte order. So, if the rawmode + # contains I;16, we need to convert from native to image + # byte order. + elif rawmode == "I;16": + rawmode = "I;16N" + elif rawmode.endswith(";16B") or rawmode.endswith(";16L"): + rawmode = rawmode[:-1] + "N" # Offset in the tile tuple is 0, we go from 0,0 to # w,h, and we only do this once -- eds a = (rawmode, self._compression, False, self.tag_v2.offset) - self.tile.append(("libtiff", (0, 0, xsize, ysize), 0, a)) + self.tile.append(ImageFile._Tile("libtiff", (0, 0, xsize, ysize), 0, a)) elif STRIPOFFSETS in self.tag_v2 or TILEOFFSETS in self.tag_v2: # striped image if STRIPOFFSETS in self.tag_v2: offsets = self.tag_v2[STRIPOFFSETS] h = self.tag_v2.get(ROWSPERSTRIP, ysize) - w = self.size[0] + w = xsize else: # tiled image offsets = self.tag_v2[TILEOFFSETS] - w = self.tag_v2.get(322) - h = self.tag_v2.get(323) + tilewidth = self.tag_v2.get(TILEWIDTH) + h = self.tag_v2.get(TILELENGTH) + if not isinstance(tilewidth, int) or not isinstance(h, int): + msg = "Invalid tile dimensions" + raise ValueError(msg) + w = tilewidth for offset in offsets: if x + w > xsize: @@ -1423,24 +1617,25 @@ def _setup(self): # adjust stride width accordingly stride /= bps_count - a = (tile_rawmode, int(stride), 1) + args = (tile_rawmode, int(stride), 1) self.tile.append( - ( + ImageFile._Tile( self._compression, (x, y, min(x + w, xsize), min(y + h, ysize)), offset, - a, + args, ) ) x = x + w - if x >= self.size[0]: + if x >= xsize: x, y = 0, y + h - if y >= self.size[1]: + if y >= ysize: x = y = 0 layer += 1 else: logger.debug("- unsupported data organization") - raise SyntaxError("unknown data organization") + msg = "unknown data organization" + raise SyntaxError(msg) # Fix up info. if ICCPROFILE in self.tag_v2: @@ -1452,17 +1647,6 @@ def _setup(self): palette = [o8(b // 256) for b in self.tag_v2[COLORMAP]] self.palette = ImagePalette.raw("RGB;L", b"".join(palette)) - self._tile_orientation = self.tag_v2.get(0x0112) - - def _close__fp(self): - try: - if self.__fp != self.fp: - self.__fp.close() - except AttributeError: - pass - finally: - self.__fp = None - # # -------------------------------------------------------------------- @@ -1496,18 +1680,27 @@ def _close__fp(self): } -def _save(im, fp, filename): - +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, prefix, photo, format, bits, extra = SAVE_INFO[im.mode] except KeyError as e: - raise OSError(f"cannot write mode {im.mode} as TIFF") from e - - ifd = ImageFileDirectory_v2(prefix=prefix) + msg = f"cannot write mode {im.mode} as TIFF" + raise OSError(msg) from e encoderinfo = im.encoderinfo encoderconfig = im.encoderconfig - compression = encoderinfo.get("compression", im.info.get("compression")) + + ifd = ImageFileDirectory_v2(prefix=prefix) + if encoderinfo.get("big_tiff"): + ifd._bigtiff = True + + try: + compression = encoderinfo["compression"] + except KeyError: + compression = im.info.get("compression") + if isinstance(compression, int): + # compression value may be from BMP. Ignore it + compression = None if compression is None: compression = "raw" elif compression == "tiff_jpeg": @@ -1519,7 +1712,7 @@ def _save(im, fp, filename): libtiff = WRITE_LIBTIFF or compression != "raw" # required for color libtiff images - ifd[PLANAR_CONFIGURATION] = getattr(im, "_planar_configuration", 1) + ifd[PLANAR_CONFIGURATION] = 1 ifd[IMAGEWIDTH] = im.size[0] ifd[IMAGELENGTH] = im.size[1] @@ -1535,11 +1728,11 @@ def _save(im, fp, filename): info = exif else: info = {} - logger.debug("Tiffinfo Keys: %s" % list(info)) + logger.debug("Tiffinfo Keys: %s", list(info)) if isinstance(info, ImageFileDirectory_v1): info = info.to_v2() for key in info: - if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS.keys(): + if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS: ifd[key] = info.get_ifd(key) else: ifd[key] = info.get(key) @@ -1548,6 +1741,20 @@ def _save(im, fp, filename): except Exception: pass # might not be an IFD. Might not have populated type + legacy_ifd = {} + if hasattr(im, "tag"): + legacy_ifd = im.tag.to_v2() + + supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + for tag in ( + # IFD offset that may not be correct in the saved image + EXIFIFD, + # Determined by the image format and should not be copied from legacy_ifd. + SAMPLEFORMAT, + ): + if tag in supplied_tags: + del supplied_tags[tag] + # additions written by Greg Couch, gregc@cgl.ucsf.edu # inspired by image-sig posting from Kevin Cazabon, kcazabon@home.com if hasattr(im, "tag_v2"): @@ -1561,8 +1768,14 @@ def _save(im, fp, filename): XMP, ): if key in im.tag_v2: - ifd[key] = im.tag_v2[key] - ifd.tagtype[key] = im.tag_v2.tagtype[key] + if key == IPTC_NAA_CHUNK and im.tag_v2.tagtype[key] not in ( + TiffTags.BYTE, + TiffTags.UNDEFINED, + ): + del supplied_tags[key] + else: + ifd[key] = im.tag_v2[key] + ifd.tagtype[key] = im.tag_v2.tagtype[key] # preserve ICC profile (should also work when saving other formats # which support profiles as TIFF) -- 2008-06-06 Florian Hoech @@ -1606,35 +1819,44 @@ def _save(im, fp, filename): if im.mode == "1": inverted_im = im.copy() px = inverted_im.load() - for y in range(inverted_im.height): - for x in range(inverted_im.width): - px[x, y] = 0 if px[x, y] == 255 else 255 - im = inverted_im + if px is not None: + for y in range(inverted_im.height): + for x in range(inverted_im.width): + px[x, y] = 0 if px[x, y] == 255 else 255 + im = inverted_im else: im = ImageOps.invert(im) if im.mode in ["P", "PA"]: lut = im.im.getpalette("RGB", "RGB;L") - ifd[COLORMAP] = tuple(v * 256 for v in lut) + colormap = [] + colors = len(lut) // 3 + for i in range(3): + colormap += [v * 256 for v in lut[colors * i : colors * (i + 1)]] + colormap += [0] * (256 - colors) + ifd[COLORMAP] = colormap # data orientation - stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8) - # aim for given strip size (64 KB by default) when using libtiff writer - if libtiff: - rows_per_strip = 1 if stride == 0 else min(STRIP_SIZE // stride, im.size[1]) - # JPEG encoder expects multiple of 8 rows - if compression == "jpeg": - rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1]) - else: - rows_per_strip = im.size[1] - if rows_per_strip == 0: - rows_per_strip = 1 - strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip - strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip - ifd[ROWSPERSTRIP] = rows_per_strip - if strip_byte_counts >= 2 ** 16: + w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] + stride = len(bits) * ((w * bits[0] + 7) // 8) + if ROWSPERSTRIP not in ifd: + # aim for given strip size (64 KB by default) when using libtiff writer + if libtiff: + im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) + rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h) + else: + rows_per_strip = h + if rows_per_strip == 0: + rows_per_strip = 1 + ifd[ROWSPERSTRIP] = rows_per_strip + strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP] + strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP] + if strip_byte_counts >= 2**16: ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( - stride * im.size[1] - strip_byte_counts * (strips_per_image - 1), + stride * h - strip_byte_counts * (strips_per_image - 1), ) ifd[STRIPOFFSETS] = tuple( range(0, strip_byte_counts * strips_per_image, strip_byte_counts) @@ -1643,73 +1865,69 @@ def _save(im, fp, filename): ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1) if im.mode == "YCbCr": - for tag, value in { + for tag, default_value in { YCBCRSUBSAMPLING: (1, 1), REFERENCEBLACKWHITE: (0, 255, 128, 255, 128, 255), }.items(): - ifd.setdefault(tag, value) + ifd.setdefault(tag, default_value) + blocklist = [TILEWIDTH, TILELENGTH, TILEOFFSETS, TILEBYTECOUNTS] if libtiff: if "quality" in encoderinfo: quality = encoderinfo["quality"] if not isinstance(quality, int) or quality < 0 or quality > 100: - raise ValueError("Invalid quality setting") + msg = "Invalid quality setting" + raise ValueError(msg) if compression != "jpeg": - raise ValueError( - "quality setting only supported for 'jpeg' compression" - ) + msg = "quality setting only supported for 'jpeg' compression" + raise ValueError(msg) ifd[JPEGQUALITY] = quality logger.debug("Saving using libtiff encoder") - logger.debug("Items: %s" % sorted(ifd.items())) + logger.debug("Items: %s", sorted(ifd.items())) _fp = 0 if hasattr(fp, "fileno"): try: fp.seek(0) - _fp = os.dup(fp.fileno()) + _fp = fp.fileno() except io.UnsupportedOperation: pass # optional types for non core tags types = {} - # SAMPLEFORMAT is determined by the image format and should not be copied - # from legacy_ifd. # STRIPOFFSETS and STRIPBYTECOUNTS are added by the library # based on the data in the strip. + # OSUBFILETYPE is deprecated. # The other tags expect arrays with a certain length (fixed or depending on # BITSPERSAMPLE, etc), passing arrays with a different length will result in # segfaults. Block these tags until we add extra validation. # SUBIFD may also cause a segfault. - blocklist = [ + blocklist += [ + OSUBFILETYPE, REFERENCEBLACKWHITE, - SAMPLEFORMAT, STRIPBYTECOUNTS, STRIPOFFSETS, TRANSFERFUNCTION, SUBIFD, ] - atts = {} # bits per sample is a single short in the tiff directory, not a list. - atts[BITSPERSAMPLE] = bits[0] + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. - legacy_ifd = {} - if hasattr(im, "tag"): - legacy_ifd = im.tag.to_v2() - for tag, value in itertools.chain( - ifd.items(), getattr(im, "tag_v2", {}).items(), legacy_ifd.items() - ): + for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): # Libtiff can only process certain core items without adding # them to the custom dictionary. # Custom items are supported for int, float, unicode, string and byte # values. Other types and tuples require a tagtype. if tag not in TiffTags.LIBTIFF_CORE: - if not Image.core.libtiff_support_custom_tags: + if not getattr(Image.core, "libtiff_support_custom_tags", False): continue - if tag in ifd.tagtype: + if tag in TiffTags.TAGS_V2_GROUPS: + types[tag] = TiffTags.LONG8 + elif tag in ifd.tagtype: types[tag] = ifd.tagtype[tag] elif not (isinstance(value, (int, float, str, bytes))): continue @@ -1725,7 +1943,10 @@ def _save(im, fp, filename): else: atts[tag] = value - logger.debug("Converted items: %s" % sorted(atts.items())) + if SAMPLEFORMAT in atts and len(atts[SAMPLEFORMAT]) == 1: + atts[SAMPLEFORMAT] = atts[SAMPLEFORMAT][0] + + logger.debug("Converted items: %s", sorted(atts.items())) # libtiff always expects the bytes in native order. # we're storing image byte order. So, if the rawmode @@ -1740,32 +1961,36 @@ def _save(im, fp, filename): tags = list(atts.items()) tags.sort() a = (rawmode, compression, _fp, filename, tags, types) - e = Image._getencoder(im.mode, "libtiff", a, encoderconfig) - e.setimage(im.im, (0, 0) + im.size) + encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) + encoder.setimage(im.im, (0, 0) + im.size) while True: - # undone, change to self.decodermaxblock: - l, s, d = e.encode(16 * 1024) + errcode, data = encoder.encode(ImageFile.MAXBLOCK)[1:] if not _fp: - fp.write(d) - if s: + fp.write(data) + if errcode: break - if s < 0: - raise OSError(f"encoder error {s} when writing image file") + if errcode < 0: + msg = f"encoder error {errcode} when writing image file" + raise OSError(msg) else: + for tag in blocklist: + del ifd[tag] offset = ifd.save(fp) ImageFile._save( - im, fp, [("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))] + im, + fp, + [ImageFile._Tile("raw", (0, 0) + im.size, offset, (rawmode, stride, 1))], ) # -- helper for multi-page save -- if "_debug_multipage" in encoderinfo: # just to access o32 and o16 (using correct byte order) - im._debug_multipage = ifd + setattr(im, "_debug_multipage", ifd) -class AppendingTiffWriter: +class AppendingTiffWriter(io.BytesIO): fieldSizes = [ 0, # None 1, # byte @@ -1780,216 +2005,236 @@ class AppendingTiffWriter: 8, # srational 4, # float 8, # double + 4, # ifd + 2, # unicode + 4, # complex + 8, # long8 ] - # StripOffsets = 273 - # FreeOffsets = 288 - # TileOffsets = 324 - # JPEGQTables = 519 - # JPEGDCTables = 520 - # JPEGACTables = 521 - Tags = {273, 288, 324, 519, 520, 521} - - def __init__(self, fn, new=False): - if hasattr(fn, "read"): - self.f = fn - self.close_fp = False - else: + Tags = { + 273, # StripOffsets + 288, # FreeOffsets + 324, # TileOffsets + 519, # JPEGQTables + 520, # JPEGDCTables + 521, # JPEGACTables + } + + def __init__(self, fn: StrOrBytesPath | IO[bytes], new: bool = False) -> None: + self.f: IO[bytes] + if is_path(fn): self.name = fn self.close_fp = True try: self.f = open(fn, "w+b" if new else "r+b") except OSError: self.f = open(fn, "w+b") + else: + self.f = cast(IO[bytes], fn) + self.close_fp = False self.beginning = self.f.tell() self.setup() - def setup(self): + def setup(self) -> None: # Reset everything. self.f.seek(self.beginning, os.SEEK_SET) - self.whereToWriteNewIFDOffset = None + self.whereToWriteNewIFDOffset: int | None = None self.offsetOfNewPage = 0 - self.IIMM = IIMM = self.f.read(4) - if not IIMM: + self.IIMM = iimm = self.f.read(4) + if not iimm: # empty file - first page self.isFirst = True return self.isFirst = False - if IIMM == b"II\x2a\x00": + if iimm == b"II\x2a\x00": self.setEndian("<") - elif IIMM == b"MM\x00\x2a": + elif iimm == b"MM\x00\x2a": self.setEndian(">") else: - raise RuntimeError("Invalid TIFF file header") + msg = "Invalid TIFF file header" + raise RuntimeError(msg) self.skipIFDs() self.goToEnd() - def finalize(self): + def finalize(self) -> None: if self.isFirst: return # fix offsets self.f.seek(self.offsetOfNewPage) - IIMM = self.f.read(4) - if not IIMM: - # raise RuntimeError("nothing written into new page") + iimm = self.f.read(4) + if not iimm: # Make it easy to finish a frame without committing to a new one. return - if IIMM != self.IIMM: - raise RuntimeError("IIMM of new page doesn't match IIMM of first page") + if iimm != self.IIMM: + msg = "IIMM of new page doesn't match IIMM of first page" + raise RuntimeError(msg) - IFDoffset = self.readLong() - IFDoffset += self.offsetOfNewPage + ifd_offset = self.readLong() + ifd_offset += self.offsetOfNewPage + assert self.whereToWriteNewIFDOffset is not None self.f.seek(self.whereToWriteNewIFDOffset) - self.writeLong(IFDoffset) - self.f.seek(IFDoffset) + self.writeLong(ifd_offset) + self.f.seek(ifd_offset) self.fixIFD() - def newFrame(self): + def newFrame(self) -> None: # Call this to finish a frame. self.finalize() self.setup() - def __enter__(self): + def __enter__(self) -> AppendingTiffWriter: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: if self.close_fp: self.close() - return False - def tell(self): + def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage - def seek(self, offset, whence=io.SEEK_SET): + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: + """ + :param offset: Distance to seek. + :param whence: Whether the distance is relative to the start, + end or current position. + :returns: The resulting position, relative to the start. + """ if whence == os.SEEK_SET: offset += self.offsetOfNewPage self.f.seek(offset, whence) return self.tell() - def goToEnd(self): + def goToEnd(self) -> None: self.f.seek(0, os.SEEK_END) pos = self.f.tell() # pad to 16 byte boundary - padBytes = 16 - pos % 16 - if 0 < padBytes < 16: - self.f.write(bytes(padBytes)) + pad_bytes = 16 - pos % 16 + if 0 < pad_bytes < 16: + self.f.write(bytes(pad_bytes)) self.offsetOfNewPage = self.f.tell() - def setEndian(self, endian): + def setEndian(self, endian: str) -> None: self.endian = endian - self.longFmt = self.endian + "L" - self.shortFmt = self.endian + "H" - self.tagFormat = self.endian + "HHL" + self.longFmt = f"{self.endian}L" + self.shortFmt = f"{self.endian}H" + self.tagFormat = f"{self.endian}HHL" - def skipIFDs(self): + def skipIFDs(self) -> None: while True: - IFDoffset = self.readLong() - if IFDoffset == 0: + ifd_offset = self.readLong() + if ifd_offset == 0: self.whereToWriteNewIFDOffset = self.f.tell() - 4 break - self.f.seek(IFDoffset) - numTags = self.readShort() - self.f.seek(numTags * 12, os.SEEK_CUR) + self.f.seek(ifd_offset) + num_tags = self.readShort() + self.f.seek(num_tags * 12, os.SEEK_CUR) - def write(self, data): + def write(self, data: Buffer, /) -> int: return self.f.write(data) - def readShort(self): - (value,) = struct.unpack(self.shortFmt, self.f.read(2)) + def _fmt(self, field_size: int) -> str: + try: + return {2: "H", 4: "L", 8: "Q"}[field_size] + except KeyError: + msg = "offset is not supported" + raise RuntimeError(msg) + + def _read(self, field_size: int) -> int: + (value,) = struct.unpack( + self.endian + self._fmt(field_size), self.f.read(field_size) + ) return value - def readLong(self): - (value,) = struct.unpack(self.longFmt, self.f.read(4)) - return value + def readShort(self) -> int: + return self._read(2) - def rewriteLastShortToLong(self, value): - self.f.seek(-2, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") + def readLong(self) -> int: + return self._read(4) + + @staticmethod + def _verify_bytes_written(bytes_written: int | None, expected: int) -> None: + if bytes_written is not None and bytes_written != expected: + msg = f"wrote only {bytes_written} bytes but wanted {expected}" + raise RuntimeError(msg) - def rewriteLastShort(self, value): + def rewriteLastShortToLong(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.shortFmt, value)) - if bytesWritten is not None and bytesWritten != 2: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 2") - - def rewriteLastLong(self, value): - self.f.seek(-4, os.SEEK_CUR) - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") - - def writeShort(self, value): - bytesWritten = self.f.write(struct.pack(self.shortFmt, value)) - if bytesWritten is not None and bytesWritten != 2: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 2") - - def writeLong(self, value): - bytesWritten = self.f.write(struct.pack(self.longFmt, value)) - if bytesWritten is not None and bytesWritten != 4: - raise RuntimeError(f"wrote only {bytesWritten} bytes but wanted 4") - - def close(self): + bytes_written = self.f.write(struct.pack(self.longFmt, value)) + self._verify_bytes_written(bytes_written, 4) + + def _rewriteLast(self, value: int, field_size: int) -> None: + self.f.seek(-field_size, os.SEEK_CUR) + bytes_written = self.f.write( + struct.pack(self.endian + self._fmt(field_size), value) + ) + self._verify_bytes_written(bytes_written, field_size) + + def rewriteLastShort(self, value: int) -> None: + return self._rewriteLast(value, 2) + + def rewriteLastLong(self, value: int) -> None: + return self._rewriteLast(value, 4) + + def writeShort(self, value: int) -> None: + bytes_written = self.f.write(struct.pack(self.shortFmt, value)) + self._verify_bytes_written(bytes_written, 2) + + def writeLong(self, value: int) -> None: + bytes_written = self.f.write(struct.pack(self.longFmt, value)) + self._verify_bytes_written(bytes_written, 4) + + def close(self) -> None: self.finalize() - self.f.close() + if self.close_fp: + self.f.close() - def fixIFD(self): - numTags = self.readShort() + def fixIFD(self) -> None: + num_tags = self.readShort() - for i in range(numTags): - tag, fieldType, count = struct.unpack(self.tagFormat, self.f.read(8)) + for i in range(num_tags): + tag, field_type, count = struct.unpack(self.tagFormat, self.f.read(8)) - fieldSize = self.fieldSizes[fieldType] - totalSize = fieldSize * count - isLocal = totalSize <= 4 - if not isLocal: - offset = self.readLong() - offset += self.offsetOfNewPage + field_size = self.fieldSizes[field_type] + total_size = field_size * count + is_local = total_size <= 4 + if not is_local: + offset = self.readLong() + self.offsetOfNewPage self.rewriteLastLong(offset) if tag in self.Tags: - curPos = self.f.tell() + cur_pos = self.f.tell() - if isLocal: - self.fixOffsets( - count, isShort=(fieldSize == 2), isLong=(fieldSize == 4) - ) - self.f.seek(curPos + 4) + if is_local: + self._fixOffsets(count, field_size) + self.f.seek(cur_pos + 4) else: self.f.seek(offset) - self.fixOffsets( - count, isShort=(fieldSize == 2), isLong=(fieldSize == 4) - ) - self.f.seek(curPos) + self._fixOffsets(count, field_size) + self.f.seek(cur_pos) - offset = curPos = None - - elif isLocal: + elif is_local: # skip the locally stored value that is not an offset self.f.seek(4, os.SEEK_CUR) - def fixOffsets(self, count, isShort=False, isLong=False): - if not isShort and not isLong: - raise RuntimeError("offset is neither short nor long") - + def _fixOffsets(self, count: int, field_size: int) -> None: for i in range(count): - offset = self.readShort() if isShort else self.readLong() + offset = self._read(field_size) offset += self.offsetOfNewPage - if isShort and offset >= 65536: + if field_size == 2 and offset >= 65536: # offset is now too large - we must convert shorts to longs if count != 1: - raise RuntimeError("not implemented") # XXX TODO + msg = "not implemented" + raise RuntimeError(msg) # XXX TODO # simple case - the offset is just one and therefore it is # local (not referenced with another offset) @@ -1997,13 +2242,22 @@ def fixOffsets(self, count, isShort=False, isLong=False): self.f.seek(-10, os.SEEK_CUR) self.writeShort(TiffTags.LONG) # rewrite the type to LONG self.f.seek(8, os.SEEK_CUR) - elif isShort: - self.rewriteLastShort(offset) else: - self.rewriteLastLong(offset) + self._rewriteLast(offset, field_size) + + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: + if isShort: + field_size = 2 + elif isLong: + field_size = 4 + else: + field_size = 0 + return self._fixOffsets(count, field_size) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) diff --git a/dependencies/WIN/PIL/TiffTags.py b/dependencies/WIN/PIL/TiffTags.py index 88856aa..86adaa4 100644 --- a/dependencies/WIN/PIL/TiffTags.py +++ b/dependencies/WIN/PIL/TiffTags.py @@ -16,28 +16,48 @@ # This module provides constants and clear-text names for various # well-known TIFF tags. ## +from __future__ import annotations -from collections import namedtuple +from typing import NamedTuple -class TagInfo(namedtuple("_TagInfo", "value name type length enum")): - __slots__ = [] +class _TagInfo(NamedTuple): + value: int | None + name: str + type: int | None + length: int | None + enum: dict[str, int] - def __new__(cls, value=None, name="unknown", type=None, length=None, enum=None): + +class TagInfo(_TagInfo): + __slots__: list[str] = [] + + def __new__( + cls, + value: int | None = None, + name: str = "unknown", + type: int | None = None, + length: int | None = None, + enum: dict[str, int] | None = None, + ) -> TagInfo: return super().__new__(cls, value, name, type, length, enum or {}) - def cvt_enum(self, value): + def cvt_enum(self, value: str) -> int | str: # Using get will call hash(value), which can be expensive # for some types (e.g. Fraction). Since self.enum is rarely # used, it's usually better to test it first. return self.enum.get(value, value) if self.enum else value -def lookup(tag, group=None): +def lookup(tag: int, group: int | None = None) -> TagInfo: """ :param tag: Integer tag number - :returns: Taginfo namedtuple, From the TAGS_V2 info if possible, - otherwise just populating the value and name from TAGS. + :param group: Which :py:data:`~PIL.TiffTags.TAGS_V2_GROUPS` to look in + + .. versionadded:: 8.3.0 + + :returns: Taginfo namedtuple, From the ``TAGS_V2`` info if possible, + otherwise just populating the value and name from ``TAGS``. If the tag is not recognized, "unknown" is returned for the name """ @@ -52,7 +72,7 @@ def lookup(tag, group=None): ## # Map tag numbers to tag info. # -# id: (Name, Type, Length, enum_values) +# id: (Name, Type, Length[, enum_values]) # # The length here differs from the length in the tiff spec. For # numbers, the tiff spec is for the number of fields returned. We @@ -74,8 +94,9 @@ def lookup(tag, group=None): FLOAT = 11 DOUBLE = 12 IFD = 13 +LONG8 = 16 -TAGS_V2 = { +_tags_v2: dict[int, tuple[str, int, int] | tuple[str, int, int, dict[str, int]]] = { 254: ("NewSubfileType", LONG, 1), 255: ("SubfileType", SHORT, 1), 256: ("ImageWidth", LONG, 1), @@ -155,6 +176,7 @@ def lookup(tag, group=None): 323: ("TileLength", LONG, 1), 324: ("TileOffsets", LONG, 0), 325: ("TileByteCounts", LONG, 0), + 330: ("SubIFDs", LONG, 0), 332: ("InkSet", SHORT, 1), 333: ("InkNames", ASCII, 1), 334: ("NumberOfInks", SHORT, 1), @@ -189,6 +211,7 @@ def lookup(tag, group=None): 34675: ("ICCProfile", UNDEFINED, 1), 34853: ("GPSInfoIFD", LONG, 1), 36864: ("ExifVersion", UNDEFINED, 1), + 37724: ("ImageSourceData", UNDEFINED, 1), 40965: ("InteroperabilityIFD", LONG, 1), 41730: ("CFAPattern", UNDEFINED, 1), # MPInfo @@ -217,7 +240,7 @@ def lookup(tag, group=None): 50838: ("ImageJMetaDataByteCounts", LONG, 0), # Can be more than one 50839: ("ImageJMetaData", UNDEFINED, 1), # see Issue #2006 } -TAGS_V2_GROUPS = { +_tags_v2_groups = { # ExifIFD 34665: { 36864: ("ExifVersion", UNDEFINED, 1), @@ -226,14 +249,46 @@ def lookup(tag, group=None): 41730: ("CFAPattern", UNDEFINED, 1), }, # GPSInfoIFD - 34853: {}, + 34853: { + 0: ("GPSVersionID", BYTE, 4), + 1: ("GPSLatitudeRef", ASCII, 2), + 2: ("GPSLatitude", RATIONAL, 3), + 3: ("GPSLongitudeRef", ASCII, 2), + 4: ("GPSLongitude", RATIONAL, 3), + 5: ("GPSAltitudeRef", BYTE, 1), + 6: ("GPSAltitude", RATIONAL, 1), + 7: ("GPSTimeStamp", RATIONAL, 3), + 8: ("GPSSatellites", ASCII, 0), + 9: ("GPSStatus", ASCII, 2), + 10: ("GPSMeasureMode", ASCII, 2), + 11: ("GPSDOP", RATIONAL, 1), + 12: ("GPSSpeedRef", ASCII, 2), + 13: ("GPSSpeed", RATIONAL, 1), + 14: ("GPSTrackRef", ASCII, 2), + 15: ("GPSTrack", RATIONAL, 1), + 16: ("GPSImgDirectionRef", ASCII, 2), + 17: ("GPSImgDirection", RATIONAL, 1), + 18: ("GPSMapDatum", ASCII, 0), + 19: ("GPSDestLatitudeRef", ASCII, 2), + 20: ("GPSDestLatitude", RATIONAL, 3), + 21: ("GPSDestLongitudeRef", ASCII, 2), + 22: ("GPSDestLongitude", RATIONAL, 3), + 23: ("GPSDestBearingRef", ASCII, 2), + 24: ("GPSDestBearing", RATIONAL, 1), + 25: ("GPSDestDistanceRef", ASCII, 2), + 26: ("GPSDestDistance", RATIONAL, 1), + 27: ("GPSProcessingMethod", UNDEFINED, 0), + 28: ("GPSAreaInformation", UNDEFINED, 0), + 29: ("GPSDateStamp", ASCII, 11), + 30: ("GPSDifferential", SHORT, 1), + }, # InteroperabilityIFD 40965: {1: ("InteropIndex", ASCII, 1), 2: ("InteropVersion", UNDEFINED, 1)}, } # Legacy Tags structure # these tags aren't included above, but were in the previous versions -TAGS = { +TAGS: dict[int | tuple[int, int], str] = { 347: "JPEGTables", 700: "XMP", # Additional Exif Info @@ -274,7 +329,7 @@ def lookup(tag, group=None): 34910: "HylaFAX FaxRecvTime", 36864: "ExifVersion", 36867: "DateTimeOriginal", - 36868: "DateTImeDigitized", + 36868: "DateTimeDigitized", 37121: "ComponentsConfiguration", 37122: "CompressedBitsPerPixel", 37724: "ImageSourceData", @@ -377,9 +432,12 @@ def lookup(tag, group=None): 50784: "Alias Layer Metadata", } +TAGS_V2: dict[int, TagInfo] = {} +TAGS_V2_GROUPS: dict[int, dict[int, TagInfo]] = {} + -def _populate(): - for k, v in TAGS_V2.items(): +def _populate() -> None: + for k, v in _tags_v2.items(): # Populate legacy structure. TAGS[k] = v[0] if len(v) == 4: @@ -388,32 +446,15 @@ def _populate(): TAGS_V2[k] = TagInfo(k, *v) - for group, tags in TAGS_V2_GROUPS.items(): - for k, v in tags.items(): - tags[k] = TagInfo(k, *v) + for group, tags in _tags_v2_groups.items(): + TAGS_V2_GROUPS[group] = {k: TagInfo(k, *v) for k, v in tags.items()} _populate() ## # Map type numbers to type names -- defined in ImageFileDirectory. -TYPES = {} - -# was: -# TYPES = { -# 1: "byte", -# 2: "ascii", -# 3: "short", -# 4: "long", -# 5: "rational", -# 6: "signed byte", -# 7: "undefined", -# 8: "signed short", -# 9: "signed long", -# 10: "signed rational", -# 11: "float", -# 12: "double", -# } +TYPES: dict[int, str] = {} # # These tags are handled by default in libtiff, without diff --git a/dependencies/WIN/PIL/WalImageFile.py b/dependencies/WIN/PIL/WalImageFile.py index 1354ad3..87e3287 100644 --- a/dependencies/WIN/PIL/WalImageFile.py +++ b/dependencies/WIN/PIL/WalImageFile.py @@ -22,18 +22,21 @@ is not registered for use with :py:func:`PIL.Image.open()`. To open a WAL file, use the :py:func:`PIL.WalImageFile.open()` function instead. """ +from __future__ import annotations + +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 +from ._typing import StrOrBytesPath class WalImageFile(ImageFile.ImageFile): - format = "WAL" format_description = "Quake2 Texture" - def _open(self): - self.mode = "P" + def _open(self) -> None: + self._mode = "P" # read header fields header = self.fp.read(32 + 24 + 32 + 12) @@ -50,18 +53,15 @@ def _open(self): if next_name: self.info["next_name"] = next_name - def load(self): - if self.im: - # Already loaded - return - - self.im = Image.core.new(self.mode, self.size) - self.frombytes(self.fp.read(self.size[0] * self.size[1])) - self.putpalette(quake2palette) - Image.Image.load(self) + def load(self) -> Image.core.PixelAccess | None: + if self._im is None: + self.im = Image.core.new(self.mode, self.size) + self.frombytes(self.fp.read(self.size[0] * self.size[1])) + self.putpalette(quake2palette) + return Image.Image.load(self) -def open(filename): +def open(filename: StrOrBytesPath | IO[bytes]) -> WalImageFile: """ Load texture from a Quake2 WAL texture file. diff --git a/dependencies/WIN/PIL/WebPImagePlugin.py b/dependencies/WIN/PIL/WebPImagePlugin.py index 590161f..c7f8555 100644 --- a/dependencies/WIN/PIL/WebPImagePlugin.py +++ b/dependencies/WIN/PIL/WebPImagePlugin.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from io import BytesIO +from typing import IO, Any from . import Image, ImageFile @@ -10,10 +13,6 @@ SUPPORTED = False -_VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True} - -_VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True} - _VP8_MODES_BY_IDENTIFIER = { b"VP8 ": "RGB", b"VP8X": "RGBA", @@ -21,7 +20,7 @@ } -def _accept(prefix): +def _accept(prefix: bytes) -> bool | str: is_riff_file_format = prefix[:4] == b"RIFF" is_webp_file = prefix[8:12] == b"WEBP" is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER @@ -32,32 +31,16 @@ def _accept(prefix): "image file could not be identified because WEBP support not installed" ) return True + return False class WebPImageFile(ImageFile.ImageFile): - format = "WEBP" format_description = "WebP image" __loaded = 0 __logical_frame = 0 - def _open(self): - if not _webp.HAVE_WEBPANIM: - # Legacy mode - data, width, height, self.mode, icc_profile, exif = _webp.WebPDecode( - self.fp.read() - ) - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - self._size = width, height - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.mode)] - self.n_frames = 1 - self.is_animated = False - return - + def _open(self) -> None: # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. self._decoder = _webp.WebPAnimDecoder(self.fp.read()) @@ -75,9 +58,8 @@ def _open(self): self.info["background"] = (bg_r, bg_g, bg_b, bg_a) self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self.mode = "RGB" if mode == "RGBX" else mode + self._mode = "RGB" if mode == "RGBX" else mode self.rawmode = mode - self.tile = [] # Attempt to read ICC / EXIF / XMP chunks from file icc_profile = self._decoder.get_chunk("ICCP") @@ -93,26 +75,26 @@ def _open(self): # Initialize seek state self._reset(reset=False) - def _getexif(self): + def _getexif(self) -> dict[int, Any] | None: if "exif" not in self.info: return None return self.getexif()._get_merged_dict() - def seek(self, frame): + def seek(self, frame: int) -> None: if not self._seek_check(frame): return # Set logical frame to requested position self.__logical_frame = frame - def _reset(self, reset=True): + def _reset(self, reset: bool = True) -> None: if reset: self._decoder.reset() self.__physical_frame = 0 self.__loaded = -1 self.__timestamp = 0 - def _get_next(self): + def _get_next(self) -> tuple[bytes, int, int]: # Get next frame ret = self._decoder.get_next() self.__physical_frame += 1 @@ -121,7 +103,8 @@ def _get_next(self): if ret is None: self._reset() # Reset just to be safe self.seek(0) - raise EOFError("failed to decode next frame in WebP file") + msg = "failed to decode next frame in WebP file" + raise EOFError(msg) # Compute duration data, timestamp = ret @@ -132,7 +115,7 @@ def _get_next(self): timestamp -= duration return data, timestamp, duration - def _seek(self, frame): + def _seek(self, frame: int) -> None: if self.__physical_frame == frame: return # Nothing to do if frame < self.__physical_frame: @@ -140,33 +123,39 @@ def _seek(self, frame): while self.__physical_frame < frame: self._get_next() # Advance to the requested frame - def load(self): - if _webp.HAVE_WEBPANIM: - if self.__loaded != self.__logical_frame: - self._seek(self.__logical_frame) + def load(self) -> Image.core.PixelAccess | None: + if self.__loaded != self.__logical_frame: + self._seek(self.__logical_frame) - # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration - self.__loaded = self.__logical_frame + # We need to load the image data for this frame + data, timestamp, duration = self._get_next() + self.info["timestamp"] = timestamp + self.info["duration"] = duration + self.__loaded = self.__logical_frame - # Set tile - if self.fp and self._exclusive_fp: - self.fp.close() - self.fp = BytesIO(data) - self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)] + # Set tile + if self.fp and self._exclusive_fp: + self.fp.close() + self.fp = BytesIO(data) + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.rawmode)] return super().load() - def tell(self): - if not _webp.HAVE_WEBPANIM: - return super().tell() + def load_seek(self, pos: int) -> None: + pass + def tell(self) -> int: return self.__logical_frame -def _save_all(im, fp, filename): +def _convert_frame(im: Image.Image) -> Image.Image: + # Make sure image mode is supported + if im.mode not in ("RGBX", "RGBA", "RGB"): + im = im.convert("RGBA" if im.has_transparency_data else "RGB") + return im + + +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -179,7 +168,7 @@ def _save_all(im, fp, filename): _save(im, fp, filename) return - background = (0, 0, 0, 0) + background: int | tuple[int, ...] = (0, 0, 0, 0) if "background" in encoderinfo: background = encoderinfo["background"] elif "background" in im.info: @@ -190,9 +179,11 @@ def _save_all(im, fp, filename): palette = im.getpalette() if palette: r, g, b = palette[background * 3 : (background + 1) * 3] - background = (r, g, b, 0) + background = (r, g, b, 255) + else: + background = (background, background, background, 255) - duration = im.encoderinfo.get("duration", im.info.get("duration")) + duration = im.encoderinfo.get("duration", im.info.get("duration", 0)) loop = im.encoderinfo.get("loop", 0) minimize_size = im.encoderinfo.get("minimize_size", False) kmin = im.encoderinfo.get("kmin", None) @@ -201,6 +192,7 @@ def _save_all(im, fp, filename): verbose = False lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) + alpha_quality = im.encoderinfo.get("alpha_quality", 100) method = im.encoderinfo.get("method", 0) icc_profile = im.encoderinfo.get("icc_profile") or "" exif = im.encoderinfo.get("exif", "") @@ -220,12 +212,10 @@ def _save_all(im, fp, filename): if ( not isinstance(background, (list, tuple)) or len(background) != 4 - or not all(v >= 0 and v < 256 for v in background) + or not all(0 <= v < 256 for v in background) ): - raise OSError( - "Background color is not an RGBA tuple clamped to (0-255): %s" - % str(background) - ) + msg = f"Background color is not an RGBA tuple clamped to (0-255): {background}" + raise OSError(msg) # Convert to packed uint bg_r, bg_g, bg_b, bg_a = background @@ -255,33 +245,16 @@ def _save_all(im, fp, filename): for idx in range(nfr): ims.seek(idx) - ims.load() - - # Make sure image mode is supported - frame = ims - rawmode = ims.mode - if ims.mode not in _VALID_WEBP_MODES: - alpha = ( - "A" in ims.mode - or "a" in ims.mode - or (ims.mode == "P" and "A" in ims.im.getpalettemode()) - ) - rawmode = "RGBA" if alpha else "RGB" - frame = ims.convert(rawmode) - - if rawmode == "RGB": - # For faster conversion, use RGBX - rawmode = "RGBX" + + frame = _convert_frame(ims) # Append the frame to the animation encoder enc.add( - frame.tobytes("raw", rawmode), - timestamp, - frame.size[0], - frame.size[1], - rawmode, + frame.getim(), + round(timestamp), lossless, quality, + alpha_quality, method, ) @@ -296,48 +269,47 @@ def _save_all(im, fp, filename): im.seek(cur_idx) # Force encoder to flush frames - enc.add(None, timestamp, 0, 0, "", lossless, quality, 0) + enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0) # Get the final output from the encoder data = enc.assemble(icc_profile, exif, xmp) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: lossless = im.encoderinfo.get("lossless", False) quality = im.encoderinfo.get("quality", 80) + alpha_quality = im.encoderinfo.get("alpha_quality", 100) icc_profile = im.encoderinfo.get("icc_profile") or "" - exif = im.encoderinfo.get("exif", "") + exif = im.encoderinfo.get("exif", b"") if isinstance(exif, Image.Exif): exif = exif.tobytes() + if exif.startswith(b"Exif\x00\x00"): + exif = exif[6:] xmp = im.encoderinfo.get("xmp", "") method = im.encoderinfo.get("method", 4) + exact = 1 if im.encoderinfo.get("exact") else 0 - if im.mode not in _VALID_WEBP_LEGACY_MODES: - alpha = ( - "A" in im.mode - or "a" in im.mode - or (im.mode == "P" and "transparency" in im.info) - ) - im = im.convert("RGBA" if alpha else "RGB") + im = _convert_frame(im) data = _webp.WebPEncode( - im.tobytes(), - im.size[0], - im.size[1], + im.getim(), lossless, float(quality), - im.mode, + float(alpha_quality), icc_profile, method, + exact, exif, xmp, ) if data is None: - raise OSError("cannot write file as WebP (encoder returned None)") + msg = "cannot write file as WebP (encoder returned None)" + raise OSError(msg) fp.write(data) @@ -345,7 +317,6 @@ def _save(im, fp, filename): Image.register_open(WebPImageFile.format, WebPImageFile, _accept) if SUPPORTED: Image.register_save(WebPImageFile.format, _save) - if _webp.HAVE_WEBPANIM: - Image.register_save_all(WebPImageFile.format, _save_all) + Image.register_save_all(WebPImageFile.format, _save_all) Image.register_extension(WebPImageFile.format, ".webp") Image.register_mime(WebPImageFile.format, "image/webp") diff --git a/dependencies/WIN/PIL/WmfImagePlugin.py b/dependencies/WIN/PIL/WmfImagePlugin.py index 27f5d2f..48e9823 100644 --- a/dependencies/WIN/PIL/WmfImagePlugin.py +++ b/dependencies/WIN/PIL/WmfImagePlugin.py @@ -18,17 +18,19 @@ # https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MS-WMF/[MS-WMF].pdf # http://wvware.sourceforge.net/caolan/index.html # http://wvware.sourceforge.net/caolan/ora-wmf.html +from __future__ import annotations + +from typing import IO from . import Image, ImageFile from ._binary import i16le as word -from ._binary import i32le as dword from ._binary import si16le as short from ._binary import si32le as _long _handler = None -def register_handler(handler): +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific WMF image handler. @@ -41,12 +43,12 @@ def register_handler(handler): if hasattr(Image.core, "drawwmf"): # install default handler (windows only) - class WmfHandler: - def open(self, im): - im.mode = "RGB" + class WmfHandler(ImageFile.StubHandler): + def open(self, im: ImageFile.StubImageFile) -> None: + im._mode = "RGB" self.bbox = im.info["wmf_bbox"] - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: im.fp.seek(0) # rewind return Image.frombytes( "RGB", @@ -65,7 +67,7 @@ def load(self, im): # Read WMF file -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return ( prefix[:6] == b"\xd7\xcd\xc6\x9a\x00\x00" or prefix[:4] == b"\x01\x00\x00\x00" ) @@ -76,22 +78,23 @@ def _accept(prefix): class WmfStubImageFile(ImageFile.StubImageFile): - format = "WMF" format_description = "Windows Metafile" - def _open(self): + def _open(self) -> None: self._inch = None # check placable header s = self.fp.read(80) if s[:6] == b"\xd7\xcd\xc6\x9a\x00\x00": - # placeable windows metafile # get units per inch self._inch = word(s, 14) + if self._inch == 0: + msg = "Invalid inch" + raise ValueError(msg) # get bounding box x0 = short(s, 6) @@ -110,9 +113,10 @@ def _open(self): # sanity check (standard metafile header) if s[22:26] != b"\x01\x00\t\x00": - raise SyntaxError("Unsupported WMF file format") + msg = "Unsupported WMF file format" + raise SyntaxError(msg) - elif dword(s) == 1 and s[40:44] == b" EMF": + elif s[:4] == b"\x01\x00\x00\x00" and s[40:44] == b" EMF": # enhanced metafile # get bounding box @@ -127,7 +131,7 @@ def _open(self): size = x1 - x0, y1 - y0 # calculate dots per inch from bbox and frame - xdpi = 2540.0 * (x1 - y0) / (frame[2] - frame[0]) + xdpi = 2540.0 * (x1 - x0) / (frame[2] - frame[0]) ydpi = 2540.0 * (y1 - y0) / (frame[3] - frame[1]) self.info["wmf_bbox"] = x0, y0, x1, y1 @@ -138,19 +142,20 @@ def _open(self): self.info["dpi"] = xdpi, ydpi else: - raise SyntaxError("Unsupported file format") + msg = "Unsupported file format" + raise SyntaxError(msg) - self.mode = "RGB" + self._mode = "RGB" self._size = size loader = self._load() if loader: loader.open(self) - def _load(self): + def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi=None): + def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: if dpi is not None and self._inch is not None: self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] @@ -158,12 +163,13 @@ def load(self, dpi=None): (x1 - x0) * self.info["dpi"] // self._inch, (y1 - y0) * self.info["dpi"] // self._inch, ) - super().load() + return super().load() -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if _handler is None or not hasattr(_handler, "save"): - raise OSError("WMF save handler not installed") + msg = "WMF save handler not installed" + raise OSError(msg) _handler.save(im, fp, filename) diff --git a/dependencies/WIN/PIL/XVThumbImagePlugin.py b/dependencies/WIN/PIL/XVThumbImagePlugin.py index 4efedb7..7533335 100644 --- a/dependencies/WIN/PIL/XVThumbImagePlugin.py +++ b/dependencies/WIN/PIL/XVThumbImagePlugin.py @@ -16,6 +16,7 @@ # To do: # FIXME: make save work (this requires quantization support) # +from __future__ import annotations from . import Image, ImageFile, ImagePalette from ._binary import o8 @@ -32,7 +33,7 @@ ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] == _MAGIC @@ -41,15 +42,16 @@ def _accept(prefix): class XVThumbImageFile(ImageFile.ImageFile): - format = "XVThumb" format_description = "XV thumbnail image" - def _open(self): - + def _open(self) -> None: # check magic + assert self.fp is not None + if not _accept(self.fp.read(6)): - raise SyntaxError("not an XV thumbnail file") + msg = "not an XV thumbnail file" + raise SyntaxError(msg) # Skip to beginning of next line self.fp.readline() @@ -58,19 +60,22 @@ def _open(self): while True: s = self.fp.readline() if not s: - raise SyntaxError("Unexpected EOF reading XV thumbnail file") + msg = "Unexpected EOF reading XV thumbnail file" + raise SyntaxError(msg) if s[0] != 35: # ie. when not a comment: '#' break # parse header line (already read) s = s.strip().split() - self.mode = "P" + self._mode = "P" self._size = int(s[0]), int(s[1]) self.palette = ImagePalette.raw("RGB", PALETTE) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), (self.mode, 0, 1))] + self.tile = [ + ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), self.mode) + ] # -------------------------------------------------------------------- diff --git a/dependencies/WIN/PIL/XbmImagePlugin.py b/dependencies/WIN/PIL/XbmImagePlugin.py index 644cfb3..943a044 100644 --- a/dependencies/WIN/PIL/XbmImagePlugin.py +++ b/dependencies/WIN/PIL/XbmImagePlugin.py @@ -18,24 +18,26 @@ # # See the README file for information on usage and redistribution. # +from __future__ import annotations import re +from typing import IO from . import Image, ImageFile # XBM header xbm_head = re.compile( - br"\s*#define[ \t]+.*_width[ \t]+(?P[0-9]+)[\r\n]+" + rb"\s*#define[ \t]+.*_width[ \t]+(?P[0-9]+)[\r\n]+" b"#define[ \t]+.*_height[ \t]+(?P[0-9]+)[\r\n]+" b"(?P" b"#define[ \t]+[^_]*_x_hot[ \t]+(?P[0-9]+)[\r\n]+" b"#define[ \t]+[^_]*_y_hot[ \t]+(?P[0-9]+)[\r\n]+" b")?" - b"[\\000-\\377]*_bits\\[\\]" + rb"[\000-\377]*_bits\[]" ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix.lstrip()[:7] == b"#define" @@ -44,32 +46,34 @@ def _accept(prefix): class XbmImageFile(ImageFile.ImageFile): - format = "XBM" format_description = "X11 Bitmap" - def _open(self): + def _open(self) -> None: + assert self.fp is not None m = xbm_head.match(self.fp.read(512)) - if m: - - xsize = int(m.group("width")) - ysize = int(m.group("height")) + if not m: + msg = "not a XBM file" + raise SyntaxError(msg) - if m.group("hotspot"): - self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) + xsize = int(m.group("width")) + ysize = int(m.group("height")) - self.mode = "1" - self._size = xsize, ysize + if m.group("hotspot"): + self.info["hotspot"] = (int(m.group("xhot")), int(m.group("yhot"))) - self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] + self._mode = "1" + self._size = xsize, ysize + self.tile = [ImageFile._Tile("xbm", (0, 0) + self.size, m.end())] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "1": - raise OSError(f"cannot write mode {im.mode} as XBM") + msg = f"cannot write mode {im.mode} as XBM" + raise OSError(msg) fp.write(f"#define im_width {im.size[0]}\n".encode("ascii")) fp.write(f"#define im_height {im.size[1]}\n".encode("ascii")) @@ -81,7 +85,7 @@ def _save(im, fp, filename): fp.write(b"static char im_bits[] = {\n") - ImageFile._save(im, fp, [("xbm", (0, 0) + im.size, 0, None)]) + ImageFile._save(im, fp, [ImageFile._Tile("xbm", (0, 0) + im.size)]) fp.write(b"};\n") diff --git a/dependencies/WIN/PIL/XpmImagePlugin.py b/dependencies/WIN/PIL/XpmImagePlugin.py index ebd65ba..b985aa5 100644 --- a/dependencies/WIN/PIL/XpmImagePlugin.py +++ b/dependencies/WIN/PIL/XpmImagePlugin.py @@ -13,7 +13,7 @@ # # See the README file for information on usage and redistribution. # - +from __future__ import annotations import re @@ -24,7 +24,7 @@ xpm_head = re.compile(b'"([0-9]*) ([0-9]*) ([0-9]*) ([0-9]*)') -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:9] == b"/* XPM */" @@ -33,20 +33,20 @@ def _accept(prefix): class XpmImageFile(ImageFile.ImageFile): - format = "XPM" format_description = "X11 Pixel Map" - def _open(self): - + def _open(self) -> None: if not _accept(self.fp.read(9)): - raise SyntaxError("not an XPM file") + msg = "not an XPM file" + raise SyntaxError(msg) # skip forward to next string while True: s = self.fp.readline() if not s: - raise SyntaxError("broken XPM file") + msg = "broken XPM file" + raise SyntaxError(msg) m = xpm_head.match(s) if m: break @@ -57,15 +57,15 @@ def _open(self): bpp = int(m.group(4)) if pal > 256 or bpp != 1: - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) # # load palette description palette = [b"\0\0\0"] * 256 - for i in range(pal): - + for _ in range(pal): s = self.fp.readline() if s[-2:] == b"\r\n": s = s[:-2] @@ -76,14 +76,12 @@ def _open(self): s = s[2:-2].split() for i in range(0, len(s), 2): - if s[i] == b"c": - # process colour key rgb = s[i + 1] if rgb == b"None": self.info["transparency"] = c - elif rgb[0:1] == b"#": + elif rgb[:1] == b"#": # FIXME: handle colour names (see ImagePalette.py) rgb = int(rgb[1:], 16) palette[c] = ( @@ -91,30 +89,27 @@ def _open(self): ) else: # unknown colour - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) break else: - # missing colour key - raise ValueError("cannot read this XPM file") + msg = "cannot read this XPM file" + raise ValueError(msg) - self.mode = "P" + self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette)) - self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] - - def load_read(self, bytes): + self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, self.fp.tell(), "P")] + def load_read(self, read_bytes: int) -> bytes: # # load all image data in one chunk xsize, ysize = self.size - s = [None] * ysize - - for i in range(ysize): - s[i] = self.fp.readline()[1 : xsize + 1].ljust(xsize) + s = [self.fp.readline()[1 : xsize + 1].ljust(xsize) for i in range(ysize)] return b"".join(s) diff --git a/dependencies/WIN/PIL/__init__.py b/dependencies/WIN/PIL/__init__.py index 890ae44..09546fe 100644 --- a/dependencies/WIN/PIL/__init__.py +++ b/dependencies/WIN/PIL/__init__.py @@ -1,11 +1,11 @@ """Pillow (Fork of the Python Imaging Library) -Pillow is the friendly PIL fork by Alex Clark and Contributors. +Pillow is the friendly PIL fork by Jeffrey A. Clark and contributors. https://github.com/python-pillow/Pillow/ Pillow is forked from PIL 1.1.7. -PIL is the Python Imaging Library by Fredrik Lundh and Contributors. +PIL is the Python Imaging Library by Fredrik Lundh and contributors. Copyright (c) 1999 by Secret Labs AB. Use PIL.__version__ for this Pillow version. @@ -13,72 +13,14 @@ ;-) """ -import sys -import warnings +from __future__ import annotations from . import _version # VERSION was removed in Pillow 6.0.0. -__version__ = _version.__version__ - - -# PILLOW_VERSION is deprecated and will be removed in a future release. +# PILLOW_VERSION was removed in Pillow 9.0.0. # Use __version__ instead. -def _raise_version_warning(): - warnings.warn( - "PILLOW_VERSION is deprecated and will be removed in Pillow 9 (2022-01-02). " - "Use __version__ instead.", - DeprecationWarning, - stacklevel=3, - ) - - -if sys.version_info >= (3, 7): - - def __getattr__(name): - if name == "PILLOW_VERSION": - _raise_version_warning() - return __version__ - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") - - -else: - - class _Deprecated_Version(str): - def __str__(self): - _raise_version_warning() - return super().__str__() - - def __getitem__(self, key): - _raise_version_warning() - return super().__getitem__(key) - - def __eq__(self, other): - _raise_version_warning() - return super().__eq__(other) - - def __ne__(self, other): - _raise_version_warning() - return super().__ne__(other) - - def __gt__(self, other): - _raise_version_warning() - return super().__gt__(other) - - def __lt__(self, other): - _raise_version_warning() - return super().__lt__(other) - - def __ge__(self, other): - _raise_version_warning() - return super().__gt__(other) - - def __le__(self, other): - _raise_version_warning() - return super().__lt__(other) - - PILLOW_VERSION = _Deprecated_Version(__version__) - +__version__ = _version.__version__ del _version @@ -90,7 +32,7 @@ def __le__(self, other): "DcxImagePlugin", "DdsImagePlugin", "EpsImagePlugin", - "FitsStubImagePlugin", + "FitsImagePlugin", "FliImagePlugin", "FpxImagePlugin", "FtexImagePlugin", @@ -118,6 +60,7 @@ def __le__(self, other): "PngImagePlugin", "PpmImagePlugin", "PsdImagePlugin", + "QoiImagePlugin", "SgiImagePlugin", "SpiderImagePlugin", "SunImagePlugin", @@ -134,6 +77,10 @@ def __le__(self, other): class UnidentifiedImageError(OSError): """ Raised in :py:meth:`PIL.Image.open` if an image cannot be opened and identified. + + If a PNG image raises this error, setting :data:`.ImageFile.LOAD_TRUNCATED_IMAGES` + to true may allow the image to be opened after all. The setting will ignore missing + data and checksum failures. """ pass diff --git a/dependencies/WIN/PIL/__main__.py b/dependencies/WIN/PIL/__main__.py index a05323f..043156e 100644 --- a/dependencies/WIN/PIL/__main__.py +++ b/dependencies/WIN/PIL/__main__.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +import sys + from .features import pilinfo -pilinfo() +pilinfo(supported_formats="--report" not in sys.argv) diff --git a/dependencies/WIN/PIL/_binary.py b/dependencies/WIN/PIL/_binary.py index a74ee9e..4594ccc 100644 --- a/dependencies/WIN/PIL/_binary.py +++ b/dependencies/WIN/PIL/_binary.py @@ -13,21 +13,21 @@ """Binary input/output support routines.""" - +from __future__ import annotations from struct import pack, unpack_from -def i8(c): - return c if c.__class__ is int else c[0] +def i8(c: bytes) -> int: + return c[0] -def o8(i): +def o8(i: int) -> bytes: return bytes((i & 255,)) # Input, le = little endian, be = big endian -def i16le(c, o=0): +def i16le(c: bytes, o: int = 0) -> int: """ Converts a 2-bytes (16 bits) string to an unsigned integer. @@ -37,7 +37,7 @@ def i16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer. @@ -47,7 +47,7 @@ def si16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer, big endian. @@ -57,7 +57,7 @@ def si16be(c, o=0): return unpack_from(">h", c, o)[0] -def i32le(c, o=0): +def i32le(c: bytes, o: int = 0) -> int: """ Converts a 4-bytes (32 bits) string to an unsigned integer. @@ -67,7 +67,7 @@ def i32le(c, o=0): return unpack_from(" int: """ Converts a 4-bytes (32 bits) string to a signed integer. @@ -77,26 +77,36 @@ def si32le(c, o=0): return unpack_from(" int: + """ + Converts a 4-bytes (32 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">i", c, o)[0] + + +def i16be(c: bytes, o: int = 0) -> int: return unpack_from(">H", c, o)[0] -def i32be(c, o=0): +def i32be(c: bytes, o: int = 0) -> int: return unpack_from(">I", c, o)[0] # Output, le = little endian, be = big endian -def o16le(i): +def o16le(i: int) -> bytes: return pack(" bytes: return pack(" bytes: return pack(">H", i) -def o32be(i): +def o32be(i: int) -> bytes: return pack(">I", i) diff --git a/dependencies/WIN/PIL/_deprecate.py b/dependencies/WIN/PIL/_deprecate.py new file mode 100644 index 0000000..83952b3 --- /dev/null +++ b/dependencies/WIN/PIL/_deprecate.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import warnings + +from . import __version__ + + +def deprecate( + deprecated: str, + when: int | None, + replacement: str | None = None, + *, + action: str | None = None, + plural: bool = False, +) -> None: + """ + Deprecations helper. + + :param deprecated: Name of thing to be deprecated. + :param when: Pillow major version to be removed in. + :param replacement: Name of replacement. + :param action: Instead of "replacement", give a custom call to action + e.g. "Upgrade to new thing". + :param plural: if the deprecated thing is plural, needing "are" instead of "is". + + Usually of the form: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + Use [replacement] instead." + + You can leave out the replacement sentence: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd)" + + Or with another call to action: + + "[deprecated] is deprecated and will be removed in Pillow [when] (yyyy-mm-dd). + [action]." + """ + + is_ = "are" if plural else "is" + + if when is None: + removed = "a future version" + elif when <= int(__version__.split(".")[0]): + msg = f"{deprecated} {is_} deprecated and should be removed." + raise RuntimeError(msg) + elif when == 12: + removed = "Pillow 12 (2025-10-15)" + else: + msg = f"Unknown removal version: {when}. Update {__name__}?" + raise ValueError(msg) + + if replacement and action: + msg = "Use only one of 'replacement' and 'action'" + raise ValueError(msg) + + if replacement: + action = f". Use {replacement} instead." + elif action: + action = f". {action.rstrip('.')}." + else: + action = "" + + warnings.warn( + f"{deprecated} {is_} deprecated and will be removed in {removed}{action}", + DeprecationWarning, + stacklevel=3, + ) diff --git a/dependencies/WIN/PIL/_imaging.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imaging.cp311-win_amd64.pyd new file mode 100644 index 0000000..413a368 Binary files /dev/null and b/dependencies/WIN/PIL/_imaging.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imaging.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imaging.cp39-win_amd64.pyd deleted file mode 100644 index 4aee21c..0000000 Binary files a/dependencies/WIN/PIL/_imaging.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imaging.pyi b/dependencies/WIN/PIL/_imaging.pyi new file mode 100644 index 0000000..998bc52 --- /dev/null +++ b/dependencies/WIN/PIL/_imaging.pyi @@ -0,0 +1,31 @@ +from typing import Any + +class ImagingCore: + def __getitem__(self, index: int) -> float: ... + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: ... + def __setitem__( + self, xy: tuple[int, int], color: float | tuple[int, ...] + ) -> None: ... + +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/_imagingcms.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imagingcms.cp311-win_amd64.pyd new file mode 100644 index 0000000..e29c3c1 Binary files /dev/null and b/dependencies/WIN/PIL/_imagingcms.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imagingcms.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imagingcms.cp39-win_amd64.pyd deleted file mode 100644 index 51691f6..0000000 Binary files a/dependencies/WIN/PIL/_imagingcms.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imagingcms.pyi b/dependencies/WIN/PIL/_imagingcms.pyi new file mode 100644 index 0000000..ddcf93a --- /dev/null +++ b/dependencies/WIN/PIL/_imagingcms.pyi @@ -0,0 +1,143 @@ +import datetime +import sys +from typing import Literal, SupportsFloat, TypedDict + +from ._typing import CapsuleType + +littlecms_version: str | None + +_Tuple3f = tuple[float, float, float] +_Tuple2x3f = tuple[_Tuple3f, _Tuple3f] +_Tuple3x3f = tuple[_Tuple3f, _Tuple3f, _Tuple3f] + +class _IccMeasurementCondition(TypedDict): + observer: int + backing: _Tuple3f + geo: str + flare: float + illuminant_type: str + +class _IccViewingCondition(TypedDict): + illuminant: _Tuple3f + surround: _Tuple3f + illuminant_type: str + +class CmsProfile: + @property + def rendering_intent(self) -> int: ... + @property + def creation_date(self) -> datetime.datetime | None: ... + @property + def copyright(self) -> str | None: ... + @property + def target(self) -> str | None: ... + @property + def manufacturer(self) -> str | None: ... + @property + def model(self) -> str | None: ... + @property + def profile_description(self) -> str | None: ... + @property + def screening_description(self) -> str | None: ... + @property + def viewing_condition(self) -> str | None: ... + @property + def version(self) -> float: ... + @property + def icc_version(self) -> int: ... + @property + def attributes(self) -> int: ... + @property + def header_flags(self) -> int: ... + @property + def header_manufacturer(self) -> str: ... + @property + def header_model(self) -> str: ... + @property + def device_class(self) -> str: ... + @property + def connection_space(self) -> str: ... + @property + def xcolor_space(self) -> str: ... + @property + def profile_id(self) -> bytes: ... + @property + def is_matrix_shaper(self) -> bool: ... + @property + def technology(self) -> str | None: ... + @property + def colorimetric_intent(self) -> str | None: ... + @property + def perceptual_rendering_intent_gamut(self) -> str | None: ... + @property + def saturation_rendering_intent_gamut(self) -> str | None: ... + @property + def red_colorant(self) -> _Tuple2x3f | None: ... + @property + def green_colorant(self) -> _Tuple2x3f | None: ... + @property + def blue_colorant(self) -> _Tuple2x3f | None: ... + @property + def red_primary(self) -> _Tuple2x3f | None: ... + @property + def green_primary(self) -> _Tuple2x3f | None: ... + @property + def blue_primary(self) -> _Tuple2x3f | None: ... + @property + def media_white_point_temperature(self) -> float | None: ... + @property + def media_white_point(self) -> _Tuple2x3f | None: ... + @property + def media_black_point(self) -> _Tuple2x3f | None: ... + @property + def luminance(self) -> _Tuple2x3f | None: ... + @property + def chromatic_adaptation(self) -> tuple[_Tuple3x3f, _Tuple3x3f] | None: ... + @property + def chromaticity(self) -> _Tuple3x3f | None: ... + @property + def colorant_table(self) -> list[str] | None: ... + @property + def colorant_table_out(self) -> list[str] | None: ... + @property + def intent_supported(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def clut(self) -> dict[int, tuple[bool, bool, bool]] | None: ... + @property + def icc_measurement_condition(self) -> _IccMeasurementCondition | None: ... + @property + def icc_viewing_condition(self) -> _IccViewingCondition | None: ... + def is_intent_supported(self, intent: int, direction: int, /) -> int: ... + +class CmsTransform: + def apply(self, id_in: CapsuleType, id_out: CapsuleType) -> int: ... + +def profile_open(profile: str, /) -> CmsProfile: ... +def profile_frombytes(profile: bytes, /) -> CmsProfile: ... +def profile_tobytes(profile: CmsProfile, /) -> bytes: ... +def buildTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def buildProofTransform( + input_profile: CmsProfile, + output_profile: CmsProfile, + proof_profile: CmsProfile, + in_mode: str, + out_mode: str, + rendering_intent: int = 0, + proof_intent: int = 0, + cms_flags: int = 0, + /, +) -> CmsTransform: ... +def createProfile( + color_space: Literal["LAB", "XYZ", "sRGB"], color_temp: SupportsFloat = 0.0, / +) -> CmsProfile: ... + +if sys.platform == "win32": + def get_display_profile_win32(handle: int = 0, is_dc: int = 0, /) -> str | None: ... diff --git a/dependencies/WIN/PIL/_imagingft.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imagingft.cp311-win_amd64.pyd new file mode 100644 index 0000000..7f91fb9 Binary files /dev/null and b/dependencies/WIN/PIL/_imagingft.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imagingft.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imagingft.cp39-win_amd64.pyd deleted file mode 100644 index 6f57cad..0000000 Binary files a/dependencies/WIN/PIL/_imagingft.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imagingft.pyi b/dependencies/WIN/PIL/_imagingft.pyi new file mode 100644 index 0000000..9cc9822 --- /dev/null +++ b/dependencies/WIN/PIL/_imagingft.pyi @@ -0,0 +1,69 @@ +from typing import Any, Callable + +from . import ImageFont, _imaging + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + def render( + self, + string: str | bytes, + fill: Callable[[int, int], _imaging.ImagingCore], + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + stroke_width: float, + anchor: str | None, + foreground_ink_long: int, + x_start: float, + y_start: float, + /, + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... + def getsize( + self, + string: str | bytes | bytearray, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + anchor: str | None, + /, + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, + string: str | bytes, + mode: str, + dir: str | None, + features: list[str] | None, + lang: str | None, + /, + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[ImageFont.Axis]: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont( + filename: str | bytes, + size: float, + index: int, + encoding: str, + font_bytes: bytes, + layout_engine: int, +) -> Font: ... +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/_imagingmath.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imagingmath.cp311-win_amd64.pyd new file mode 100644 index 0000000..2b731aa Binary files /dev/null and b/dependencies/WIN/PIL/_imagingmath.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imagingmath.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imagingmath.cp39-win_amd64.pyd deleted file mode 100644 index 8f89253..0000000 Binary files a/dependencies/WIN/PIL/_imagingmath.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imagingmath.pyi b/dependencies/WIN/PIL/_imagingmath.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/WIN/PIL/_imagingmath.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/_imagingmorph.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imagingmorph.cp311-win_amd64.pyd new file mode 100644 index 0000000..8d2792f Binary files /dev/null and b/dependencies/WIN/PIL/_imagingmorph.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imagingmorph.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imagingmorph.cp39-win_amd64.pyd deleted file mode 100644 index 60385de..0000000 Binary files a/dependencies/WIN/PIL/_imagingmorph.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imagingmorph.pyi b/dependencies/WIN/PIL/_imagingmorph.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/WIN/PIL/_imagingmorph.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/_imagingtk.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_imagingtk.cp311-win_amd64.pyd new file mode 100644 index 0000000..824e4dd Binary files /dev/null and b/dependencies/WIN/PIL/_imagingtk.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_imagingtk.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_imagingtk.cp39-win_amd64.pyd deleted file mode 100644 index b060d2e..0000000 Binary files a/dependencies/WIN/PIL/_imagingtk.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_imagingtk.pyi b/dependencies/WIN/PIL/_imagingtk.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/WIN/PIL/_imagingtk.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/_tkinter_finder.py b/dependencies/WIN/PIL/_tkinter_finder.py index 58aeffb..beddfb0 100644 --- a/dependencies/WIN/PIL/_tkinter_finder.py +++ b/dependencies/WIN/PIL/_tkinter_finder.py @@ -1,20 +1,21 @@ """ Find compiled module linking to Tcl / Tk libraries """ + +from __future__ import annotations + import sys import tkinter -import warnings -from tkinter import _tkinter as tk -if hasattr(sys, "pypy_find_executable"): - TKINTER_LIB = tk.tklib_cffi.__file__ -else: - TKINTER_LIB = tk.__file__ +tk = getattr(tkinter, "_tkinter") + +try: + if hasattr(sys, "pypy_find_executable"): + TKINTER_LIB = tk.tklib_cffi.__file__ + else: + TKINTER_LIB = tk.__file__ +except AttributeError: + # _tkinter may be compiled directly into Python, in which case __file__ is + # not available. load_tkinter_funcs will check the binary first in any case. + TKINTER_LIB = None tk_version = str(tkinter.TkVersion) -if tk_version == "8.4": - warnings.warn( - "Support for Tk/Tcl 8.4 is deprecated and will be removed" - " in Pillow 10 (2023-01-02). Please upgrade to Tk/Tcl 8.5 " - "or newer.", - DeprecationWarning, - ) diff --git a/dependencies/WIN/PIL/_typing.py b/dependencies/WIN/PIL/_typing.py new file mode 100644 index 0000000..34a9a81 --- /dev/null +++ b/dependencies/WIN/PIL/_typing.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, Union + +if TYPE_CHECKING: + from numbers import _IntegralLike as IntegralLike + + try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] # requires numpy>=1.21 + except (ImportError, AttributeError): + pass + +if sys.version_info >= (3, 13): + from types import CapsuleType +else: + CapsuleType = object + +if sys.version_info >= (3, 12): + from collections.abc import Buffer +else: + Buffer = Any + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + try: + from typing_extensions import TypeGuard + except ImportError: + + class TypeGuard: # type: ignore[no-redef] + def __class_getitem__(cls, item: Any) -> type[bool]: + return bool + + +Coords = Union[Sequence[float], Sequence[Sequence[float]]] + + +_T_co = TypeVar("_T_co", covariant=True) + + +class SupportsRead(Protocol[_T_co]): + def read(self, length: int = ..., /) -> _T_co: ... + + +StrOrBytesPath = Union[str, bytes, os.PathLike[str], os.PathLike[bytes]] + + +__all__ = ["Buffer", "IntegralLike", "StrOrBytesPath", "SupportsRead", "TypeGuard"] diff --git a/dependencies/WIN/PIL/_util.py b/dependencies/WIN/PIL/_util.py index 0c5d389..8ef0d36 100644 --- a/dependencies/WIN/PIL/_util.py +++ b/dependencies/WIN/PIL/_util.py @@ -1,19 +1,26 @@ -import os -from pathlib import Path +from __future__ import annotations +import os +from typing import Any, NoReturn -def isPath(f): - return isinstance(f, (bytes, str, Path)) +from ._typing import StrOrBytesPath, TypeGuard -# Checks if an object is a string, and that it points to a directory. -def isDirectory(f): - return isPath(f) and os.path.isdir(f) +def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: + return isinstance(f, (bytes, str, os.PathLike)) -class deferred_error: - def __init__(self, ex): +class DeferredError: + def __init__(self, ex: BaseException): self.ex = ex - def __getattr__(self, elt): + def __getattr__(self, elt: str) -> NoReturn: raise self.ex + + @staticmethod + def new(ex: BaseException) -> Any: + """ + Creates an object that raises the wrapped exception ``ex`` when used, + and casts it to :py:obj:`~typing.Any` type. + """ + return DeferredError(ex) diff --git a/dependencies/WIN/PIL/_version.py b/dependencies/WIN/PIL/_version.py index d7f0f7e..9938a0a 100644 --- a/dependencies/WIN/PIL/_version.py +++ b/dependencies/WIN/PIL/_version.py @@ -1,2 +1,4 @@ # Master version for Pillow -__version__ = "8.4.0" +from __future__ import annotations + +__version__ = "11.1.0" diff --git a/dependencies/WIN/PIL/_webp.cp311-win_amd64.pyd b/dependencies/WIN/PIL/_webp.cp311-win_amd64.pyd new file mode 100644 index 0000000..ff17d5c Binary files /dev/null and b/dependencies/WIN/PIL/_webp.cp311-win_amd64.pyd differ diff --git a/dependencies/WIN/PIL/_webp.cp39-win_amd64.pyd b/dependencies/WIN/PIL/_webp.cp39-win_amd64.pyd deleted file mode 100644 index 1f54ebf..0000000 Binary files a/dependencies/WIN/PIL/_webp.cp39-win_amd64.pyd and /dev/null differ diff --git a/dependencies/WIN/PIL/_webp.pyi b/dependencies/WIN/PIL/_webp.pyi new file mode 100644 index 0000000..e27843e --- /dev/null +++ b/dependencies/WIN/PIL/_webp.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/dependencies/WIN/PIL/concrt140.dll b/dependencies/WIN/PIL/concrt140.dll deleted file mode 100644 index 1065145..0000000 Binary files a/dependencies/WIN/PIL/concrt140.dll and /dev/null differ diff --git a/dependencies/WIN/PIL/features.py b/dependencies/WIN/PIL/features.py index 3838568..3645e3d 100644 --- a/dependencies/WIN/PIL/features.py +++ b/dependencies/WIN/PIL/features.py @@ -1,11 +1,15 @@ +from __future__ import annotations + import collections import os import sys import warnings +from typing import IO import PIL from . import Image +from ._deprecate import deprecate modules = { "pil": ("PIL._imaging", "PILLOW_VERSION"), @@ -16,7 +20,7 @@ } -def check_module(feature): +def check_module(feature: str) -> bool: """ Checks if a module is available. @@ -24,19 +28,23 @@ def check_module(feature): :returns: ``True`` if available, ``False`` otherwise. :raises ValueError: If the module is not defined in this version of Pillow. """ - if not (feature in modules): - raise ValueError(f"Unknown module {feature}") + if feature not in modules: + msg = f"Unknown module {feature}" + raise ValueError(msg) module, ver = modules[feature] try: __import__(module) return True - except ImportError: + except ModuleNotFoundError: + return False + except ImportError as ex: + warnings.warn(str(ex)) return False -def version_module(feature): +def version_module(feature: str) -> str | None: """ :param feature: The module to check for. :returns: @@ -48,13 +56,10 @@ def version_module(feature): module, ver = modules[feature] - if ver is None: - return None - return getattr(__import__(module, fromlist=[ver]), ver) -def get_supported_modules(): +def get_supported_modules() -> list[str]: """ :returns: A list of all supported modules. """ @@ -69,7 +74,7 @@ def get_supported_modules(): } -def check_codec(feature): +def check_codec(feature: str) -> bool: """ Checks if a codec is available. @@ -78,14 +83,15 @@ def check_codec(feature): :raises ValueError: If the codec is not defined in this version of Pillow. """ if feature not in codecs: - raise ValueError(f"Unknown codec {feature}") + msg = f"Unknown codec {feature}" + raise ValueError(msg) codec, lib = codecs[feature] - return codec + "_encoder" in dir(Image.core) + return f"{codec}_encoder" in dir(Image.core) -def version_codec(feature): +def version_codec(feature: str) -> str | None: """ :param feature: The codec to check for. :returns: @@ -98,7 +104,7 @@ def version_codec(feature): codec, lib = codecs[feature] - version = getattr(Image.core, lib + "_version") + version = getattr(Image.core, f"{lib}_version") if feature == "libtiff": return version.split("\n")[0].split("Version ")[1] @@ -106,27 +112,28 @@ def version_codec(feature): return version -def get_supported_codecs(): +def get_supported_codecs() -> list[str]: """ :returns: A list of all supported codecs. """ return [f for f in codecs if check_codec(f)] -features = { - "webp_anim": ("PIL._webp", "HAVE_WEBPANIM", None), - "webp_mux": ("PIL._webp", "HAVE_WEBPMUX", None), - "transp_webp": ("PIL._webp", "HAVE_TRANSPARENCY", None), +features: dict[str, tuple[str, str | bool, str | None]] = { + "webp_anim": ("PIL._webp", True, None), + "webp_mux": ("PIL._webp", True, None), + "transp_webp": ("PIL._webp", True, None), "raqm": ("PIL._imagingft", "HAVE_RAQM", "raqm_version"), "fribidi": ("PIL._imagingft", "HAVE_FRIBIDI", "fribidi_version"), "harfbuzz": ("PIL._imagingft", "HAVE_HARFBUZZ", "harfbuzz_version"), "libjpeg_turbo": ("PIL._imaging", "HAVE_LIBJPEGTURBO", "libjpeg_turbo_version"), + "zlib_ng": ("PIL._imaging", "HAVE_ZLIBNG", "zlib_ng_version"), "libimagequant": ("PIL._imaging", "HAVE_LIBIMAGEQUANT", "imagequant_version"), "xcb": ("PIL._imaging", "HAVE_XCB", None), } -def check_feature(feature): +def check_feature(feature: str) -> bool | None: """ Checks if a feature is available. @@ -135,18 +142,26 @@ def check_feature(feature): :raises ValueError: If the feature is not defined in this version of Pillow. """ if feature not in features: - raise ValueError(f"Unknown feature {feature}") + msg = f"Unknown feature {feature}" + raise ValueError(msg) module, flag, ver = features[feature] + if isinstance(flag, bool): + deprecate(f'check_feature("{feature}")', 12) try: imported_module = __import__(module, fromlist=["PIL"]) + if isinstance(flag, bool): + return flag return getattr(imported_module, flag) - except ImportError: + except ModuleNotFoundError: + return None + except ImportError as ex: + warnings.warn(str(ex)) return None -def version_feature(feature): +def version_feature(feature: str) -> str | None: """ :param feature: The feature to check for. :returns: The version number as a string, or ``None`` if not available. @@ -163,14 +178,24 @@ def version_feature(feature): return getattr(__import__(module, fromlist=[ver]), ver) -def get_supported_features(): +def get_supported_features() -> list[str]: """ :returns: A list of all supported features. """ - return [f for f in features if check_feature(f)] - - -def check(feature): + supported_features = [] + for f, (module, flag, _) in features.items(): + if flag is True: + for feature, (feature_module, _) in modules.items(): + if feature_module == module: + if check_module(feature): + supported_features.append(f) + break + elif check_feature(f): + supported_features.append(f) + return supported_features + + +def check(feature: str) -> bool | None: """ :param feature: A module, codec, or feature name. :returns: @@ -188,7 +213,7 @@ def check(feature): return False -def version(feature): +def version(feature: str) -> str | None: """ :param feature: The module, codec, or feature to check for. @@ -204,7 +229,7 @@ def version(feature): return None -def get_supported(): +def get_supported() -> list[str]: """ :returns: A list of all supported modules, features, and codecs. """ @@ -215,10 +240,13 @@ def get_supported(): return ret -def pilinfo(out=None, supported_formats=True): +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. This function can be called with ``python3 -m PIL``. + It can also be called with ``python3 -m PIL.report`` or ``python3 -m PIL --report`` + to have "supported_formats" set to ``False``, omitting the list of all supported + image file formats. :param out: The output stream to print to. Defaults to ``sys.stdout`` if ``None``. @@ -233,17 +261,22 @@ def pilinfo(out=None, supported_formats=True): print("-" * 68, file=out) print(f"Pillow {PIL.__version__}", file=out) - py_version = sys.version.splitlines() - print(f"Python {py_version[0].strip()}", file=out) - for py_version in py_version[1:]: + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) + print(f"Python executable is {sys.executable or 'unknown'}", file=out) + if sys.prefix != sys.base_prefix: + print(f"Environment Python files loaded from {sys.prefix}", file=out) + print(f"System Python files loaded from {sys.base_prefix}", file=out) + print("-" * 68, file=out) print( - f"Python modules loaded from {os.path.dirname(Image.__file__)}", + f"Python Pillow modules loaded from {os.path.dirname(Image.__file__)}", file=out, ) print( - f"Binary modules loaded from {os.path.dirname(Image.core.__file__)}", + f"Binary Pillow modules loaded from {os.path.dirname(Image.core.__file__)}", file=out, ) print("-" * 68, file=out) @@ -254,9 +287,6 @@ def pilinfo(out=None, supported_formats=True): ("freetype2", "FREETYPE2"), ("littlecms2", "LITTLECMS2"), ("webp", "WEBP"), - ("transp_webp", "WEBP Transparency"), - ("webp_mux", "WEBPMUX"), - ("webp_anim", "WEBP Animation"), ("jpg", "JPEG"), ("jpg_2000", "OPENJPEG (JPEG2000)"), ("zlib", "ZLIB (PNG/ZIP)"), @@ -266,9 +296,12 @@ def pilinfo(out=None, supported_formats=True): ("xcb", "XCB (X protocol)"), ]: if check(name): - if name == "jpg" and check_feature("libjpeg_turbo"): - v = "libjpeg-turbo " + version_feature("libjpeg_turbo") - else: + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "libjpeg-turbo " + libjpeg_turbo_version + if v is None: v = version(name) if v is not None: version_static = name in ("pil", "jpg") @@ -276,7 +309,11 @@ def pilinfo(out=None, supported_formats=True): # this check is also in src/_imagingcms.c:setup_module() version_static = tuple(int(x) for x in v.split(".")) < (2, 7) t = "compiled for" if version_static else "loaded" - if name == "raqm": + if name == "zlib": + zlib_ng_version = version_feature("zlib_ng") + if zlib_ng_version is not None: + v += ", compiled for zlib-ng " + zlib_ng_version + elif name == "raqm": for f in ("fribidi", "harfbuzz"): v2 = version_feature(f) if v2 is not None: diff --git a/dependencies/WIN/PIL/msvcp140.dll b/dependencies/WIN/PIL/msvcp140.dll deleted file mode 100644 index 98313d4..0000000 Binary files a/dependencies/WIN/PIL/msvcp140.dll and /dev/null differ diff --git a/dependencies/WIN/PIL/msvcp140_1.dll b/dependencies/WIN/PIL/msvcp140_1.dll deleted file mode 100644 index c0253df..0000000 Binary files a/dependencies/WIN/PIL/msvcp140_1.dll and /dev/null differ diff --git a/dependencies/WIN/PIL/msvcp140_2.dll b/dependencies/WIN/PIL/msvcp140_2.dll deleted file mode 100644 index 93f00f5..0000000 Binary files a/dependencies/WIN/PIL/msvcp140_2.dll and /dev/null differ diff --git a/dependencies/WIN/PIL/py.typed b/dependencies/WIN/PIL/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dependencies/WIN/PIL/report.py b/dependencies/WIN/PIL/report.py new file mode 100644 index 0000000..d2815e8 --- /dev/null +++ b/dependencies/WIN/PIL/report.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .features import pilinfo + +pilinfo(supported_formats=False) diff --git a/dependencies/WIN/PIL/vccorlib140.dll b/dependencies/WIN/PIL/vccorlib140.dll deleted file mode 100644 index f5c486d..0000000 Binary files a/dependencies/WIN/PIL/vccorlib140.dll and /dev/null differ diff --git a/dependencies/WIN/PIL/vcruntime140.dll b/dependencies/WIN/PIL/vcruntime140.dll deleted file mode 100644 index 34a0e72..0000000 Binary files a/dependencies/WIN/PIL/vcruntime140.dll and /dev/null differ diff --git a/sketchfab/config.py b/sketchfab/config.py index 568cb58..d811bd0 100644 --- a/sketchfab/config.py +++ b/sketchfab/config.py @@ -19,7 +19,7 @@ class Config: - PLUGIN_VERSION = "1.5.0" + PLUGIN_VERSION = "1.6.0" PLUGIN_TITLE = "Sketchfab Plugin" PLUGIN_AUTHOR = "Sketchfab" PLUGIN_TWITTER = "@sketchfab" diff --git a/sketchfab/import_gltf.py b/sketchfab/import_gltf.py index 793287b..e254ae7 100644 --- a/sketchfab/import_gltf.py +++ b/sketchfab/import_gltf.py @@ -355,10 +355,10 @@ def parse_texcoords(index, c4d_mesh): uvtag.SetName(texcoord_key) for i in range(0, nb_poly): poly = c4d_mesh.GetPolygon(i) - aa = (uvs[poly.a][0], uvs[poly.a][1], 0.0) - bb = (uvs[poly.b][0], uvs[poly.b][1], 0.0) - cc = (uvs[poly.c][0], uvs[poly.c][1], 0.0) - uvtag.SetSlow(i, aa, bb, cc, (0.0, 0.0, 0.0)) + aa = c4d.Vector(uvs[poly.a][0], uvs[poly.a][1], 0.0) + bb = c4d.Vector(uvs[poly.b][0], uvs[poly.b][1], 0.0) + cc = c4d.Vector(uvs[poly.c][0], uvs[poly.c][1], 0.0) + uvtag.SetSlow(i, aa, bb, cc, c4d.Vector(0.0, 0.0, 0.0)) c4d_mesh.InsertTag(uvtag) diff --git a/sketchfab/ui_importer.py b/sketchfab/ui_importer.py index eb3e87f..edc9299 100644 --- a/sketchfab/ui_importer.py +++ b/sketchfab/ui_importer.py @@ -349,7 +349,7 @@ def draw_prev_next(self): if self.result_valid() and len(self.skfb_api.search_results['current']) > 0: self.AddButton(id=BTN_PREV_PAGE, flags=c4d.BFH_LEFT | c4d.BFV_CENTER, initw=75, inith=TEXT_WIDGET_HEIGHT, name="Previous") - self.AddSeparatorV(inith=0.0, flags=c4d.BFH_SCALE) + self.AddSeparatorV(inith=0, flags=c4d.BFH_SCALE) self.AddButton(id=BTN_NEXT_PAGE, flags=c4d.BFH_RIGHT | c4d.BFV_CENTER, initw=75, inith=TEXT_WIDGET_HEIGHT, name="Next") self.Enable(BTN_PREV_PAGE, self.skfb_api.has_prev()) self.Enable(BTN_NEXT_PAGE, self.skfb_api.has_next()) diff --git a/sketchfab/ui_login.py b/sketchfab/ui_login.py index 44866ab..8a7f5be 100644 --- a/sketchfab/ui_login.py +++ b/sketchfab/ui_login.py @@ -202,7 +202,7 @@ def draw_footer(self): self.LayoutFlushGroup(GROUP_FOOTER) self.GroupBorderSpace(6, 2, 6, 6) - self.AddSeparatorV(0.0, flags=c4d.BFH_SCALE) + self.AddSeparatorV(inith=0, flags=c4d.BFH_SCALE) self.GroupBegin(GROUP_FOOTER_VERSION, c4d.BFH_RIGHT | c4d.BFV_CENTER, 5, 1, "Footer_version") self.draw_version_ui()