This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -1,59 +0,0 @@
Jinja2-3.1.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
Jinja2-3.1.2.dist-info/LICENSE.rst,sha256=O0nc7kEF6ze6wQ-vG-JgQI_oXSUrjp3y4JefweCUQ3s,1475
Jinja2-3.1.2.dist-info/METADATA,sha256=PZ6v2SIidMNixR7MRUX9f7ZWsPwtXanknqiZUmRbh4U,3539
Jinja2-3.1.2.dist-info/RECORD,,
Jinja2-3.1.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
Jinja2-3.1.2.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
Jinja2-3.1.2.dist-info/entry_points.txt,sha256=zRd62fbqIyfUpsRtU7EVIFyiu1tPwfgO7EvPErnxgTE,59
Jinja2-3.1.2.dist-info/top_level.txt,sha256=PkeVWtLb3-CqjWi1fO29OCbj55EhX_chhKrCdrVe_zs,7
jinja2/__init__.py,sha256=8vGduD8ytwgD6GDSqpYc2m3aU-T7PKOAddvVXgGr_Fs,1927
jinja2/__pycache__/__init__.cpython-312.pyc,,
jinja2/__pycache__/_identifier.cpython-312.pyc,,
jinja2/__pycache__/async_utils.cpython-312.pyc,,
jinja2/__pycache__/bccache.cpython-312.pyc,,
jinja2/__pycache__/compiler.cpython-312.pyc,,
jinja2/__pycache__/constants.cpython-312.pyc,,
jinja2/__pycache__/debug.cpython-312.pyc,,
jinja2/__pycache__/defaults.cpython-312.pyc,,
jinja2/__pycache__/environment.cpython-312.pyc,,
jinja2/__pycache__/exceptions.cpython-312.pyc,,
jinja2/__pycache__/ext.cpython-312.pyc,,
jinja2/__pycache__/filters.cpython-312.pyc,,
jinja2/__pycache__/idtracking.cpython-312.pyc,,
jinja2/__pycache__/lexer.cpython-312.pyc,,
jinja2/__pycache__/loaders.cpython-312.pyc,,
jinja2/__pycache__/meta.cpython-312.pyc,,
jinja2/__pycache__/nativetypes.cpython-312.pyc,,
jinja2/__pycache__/nodes.cpython-312.pyc,,
jinja2/__pycache__/optimizer.cpython-312.pyc,,
jinja2/__pycache__/parser.cpython-312.pyc,,
jinja2/__pycache__/runtime.cpython-312.pyc,,
jinja2/__pycache__/sandbox.cpython-312.pyc,,
jinja2/__pycache__/tests.cpython-312.pyc,,
jinja2/__pycache__/utils.cpython-312.pyc,,
jinja2/__pycache__/visitor.cpython-312.pyc,,
jinja2/_identifier.py,sha256=_zYctNKzRqlk_murTNlzrju1FFJL7Va_Ijqqd7ii2lU,1958
jinja2/async_utils.py,sha256=dHlbTeaxFPtAOQEYOGYh_PHcDT0rsDaUJAFDl_0XtTg,2472
jinja2/bccache.py,sha256=mhz5xtLxCcHRAa56azOhphIAe19u1we0ojifNMClDio,14061
jinja2/compiler.py,sha256=Gs-N8ThJ7OWK4-reKoO8Wh1ZXz95MVphBKNVf75qBr8,72172
jinja2/constants.py,sha256=GMoFydBF_kdpaRKPoM5cl5MviquVRLVyZtfp5-16jg0,1433
jinja2/debug.py,sha256=iWJ432RadxJNnaMOPrjIDInz50UEgni3_HKuFXi2vuQ,6299
jinja2/defaults.py,sha256=boBcSw78h-lp20YbaXSJsqkAI2uN_mD_TtCydpeq5wU,1267
jinja2/environment.py,sha256=6uHIcc7ZblqOMdx_uYNKqRnnwAF0_nzbyeMP9FFtuh4,61349
jinja2/exceptions.py,sha256=ioHeHrWwCWNaXX1inHmHVblvc4haO7AXsjCp3GfWvx0,5071
jinja2/ext.py,sha256=ivr3P7LKbddiXDVez20EflcO3q2aHQwz9P_PgWGHVqE,31502
jinja2/filters.py,sha256=9js1V-h2RlyW90IhLiBGLM2U-k6SCy2F4BUUMgB3K9Q,53509
jinja2/idtracking.py,sha256=GfNmadir4oDALVxzn3DL9YInhJDr69ebXeA2ygfuCGA,10704
jinja2/lexer.py,sha256=DW2nX9zk-6MWp65YR2bqqj0xqCvLtD-u9NWT8AnFRxQ,29726
jinja2/loaders.py,sha256=BfptfvTVpClUd-leMkHczdyPNYFzp_n7PKOJ98iyHOg,23207
jinja2/meta.py,sha256=GNPEvifmSaU3CMxlbheBOZjeZ277HThOPUTf1RkppKQ,4396
jinja2/nativetypes.py,sha256=DXgORDPRmVWgy034H0xL8eF7qYoK3DrMxs-935d0Fzk,4226
jinja2/nodes.py,sha256=i34GPRAZexXMT6bwuf5SEyvdmS-bRCy9KMjwN5O6pjk,34550
jinja2/optimizer.py,sha256=tHkMwXxfZkbfA1KmLcqmBMSaz7RLIvvItrJcPoXTyD8,1650
jinja2/parser.py,sha256=nHd-DFHbiygvfaPtm9rcQXJChZG7DPsWfiEsqfwKerY,39595
jinja2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
jinja2/runtime.py,sha256=5CmD5BjbEJxSiDNTFBeKCaq8qU4aYD2v6q2EluyExms,33476
jinja2/sandbox.py,sha256=Y0xZeXQnH6EX5VjaV2YixESxoepnRbW_3UeQosaBU3M,14584
jinja2/tests.py,sha256=Am5Z6Lmfr2XaH_npIfJJ8MdXtWsbLjMULZJulTAj30E,5905
jinja2/utils.py,sha256=u9jXESxGn8ATZNVolwmkjUVu4SA-tLgV0W7PcSfPfdQ,23965
jinja2/visitor.py,sha256=MH14C6yq24G_KVtWzjwaI7Wg14PCJIYlWW1kpkxYak0,3568

View File

@@ -1,2 +0,0 @@
[babel.extractors]
jinja2 = jinja2.ext:babel_extract[i18n]

View File

@@ -0,0 +1,291 @@
from __future__ import annotations
import os
from io import BytesIO
from typing import IO
from . import ExifTags, Image, ImageFile
try:
from . import _avif
SUPPORTED = True
except ImportError:
SUPPORTED = False
# Decoder options as module globals, until there is a way to pass parameters
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
DECODE_CODEC_CHOICE = "auto"
DEFAULT_MAX_THREADS = 0
def get_codec_version(codec_name: str) -> str | None:
versions = _avif.codec_versions()
for version in versions.split(", "):
if version.split(" [")[0] == codec_name:
return version.split(":")[-1].split(" ")[0]
return None
def _accept(prefix: bytes) -> bool | str:
if prefix[4:8] != b"ftyp":
return False
major_brand = prefix[8:12]
if major_brand in (
# coding brands
b"avif",
b"avis",
# We accept files with AVIF container brands; we can't yet know if
# the ftyp box has the correct compatible brands, but if it doesn't
# then the plugin will raise a SyntaxError which Pillow will catch
# before moving on to the next plugin that accepts the file.
#
# Also, because this file might not actually be an AVIF file, we
# don't raise an error if AVIF support isn't properly compiled.
b"mif1",
b"msf1",
):
if not SUPPORTED:
return (
"image file could not be identified because AVIF support not installed"
)
return True
return False
def _get_default_max_threads() -> int:
if DEFAULT_MAX_THREADS:
return DEFAULT_MAX_THREADS
if hasattr(os, "sched_getaffinity"):
return len(os.sched_getaffinity(0))
else:
return os.cpu_count() or 1
class AvifImageFile(ImageFile.ImageFile):
format = "AVIF"
format_description = "AVIF image"
__frame = -1
def _open(self) -> None:
if not SUPPORTED:
msg = "image file could not be opened because AVIF support not installed"
raise SyntaxError(msg)
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
DECODE_CODEC_CHOICE
):
msg = "Invalid opening codec"
raise ValueError(msg)
self._decoder = _avif.AvifDecoder(
self.fp.read(),
DECODE_CODEC_CHOICE,
_get_default_max_threads(),
)
# Get info from decoder
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
self._decoder.get_info()
)
self.is_animated = self.n_frames > 1
if icc:
self.info["icc_profile"] = icc
if xmp:
self.info["xmp"] = xmp
if exif_orientation != 1 or exif:
exif_data = Image.Exif()
if exif:
exif_data.load(exif)
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
else:
original_orientation = 1
if exif_orientation != original_orientation:
exif_data[ExifTags.Base.Orientation] = exif_orientation
exif = exif_data.tobytes()
if exif:
self.info["exif"] = exif
self.seek(0)
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
# Set tile
self.__frame = frame
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
# We need to load the image data for this frame
data, timescale, pts_in_timescales, duration_in_timescales = (
self._decoder.get_frame(self.__frame)
)
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
if self.fp and self._exclusive_fp:
self.fp.close()
self.fp = BytesIO(data)
return super().load()
def load_seek(self, pos: int) -> None:
pass
def tell(self) -> int:
return self.__frame
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
_save(im, fp, filename, save_all=True)
def _save(
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
) -> None:
info = im.encoderinfo.copy()
if save_all:
append_images = list(info.get("append_images", []))
else:
append_images = []
total = 0
for ims in [im] + append_images:
total += getattr(ims, "n_frames", 1)
quality = info.get("quality", 75)
if not isinstance(quality, int) or quality < 0 or quality > 100:
msg = "Invalid quality setting"
raise ValueError(msg)
duration = info.get("duration", 0)
subsampling = info.get("subsampling", "4:2:0")
speed = info.get("speed", 6)
max_threads = info.get("max_threads", _get_default_max_threads())
codec = info.get("codec", "auto")
if codec != "auto" and not _avif.encoder_codec_available(codec):
msg = "Invalid saving codec"
raise ValueError(msg)
range_ = info.get("range", "full")
tile_rows_log2 = info.get("tile_rows", 0)
tile_cols_log2 = info.get("tile_cols", 0)
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
exif_orientation = 1
if exif := info.get("exif"):
if isinstance(exif, Image.Exif):
exif_data = exif
else:
exif_data = Image.Exif()
exif_data.load(exif)
if ExifTags.Base.Orientation in exif_data:
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
exif = exif_data.tobytes() if exif_data else b""
elif isinstance(exif, Image.Exif):
exif = exif_data.tobytes()
xmp = info.get("xmp")
if isinstance(xmp, str):
xmp = xmp.encode("utf-8")
advanced = info.get("advanced")
if advanced is not None:
if isinstance(advanced, dict):
advanced = advanced.items()
try:
advanced = tuple(advanced)
except TypeError:
invalid = True
else:
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
if invalid:
msg = (
"advanced codec options must be a dict of key-value string "
"pairs or a series of key-value two-tuples"
)
raise ValueError(msg)
# Setup the AVIF encoder
enc = _avif.AvifEncoder(
im.size,
subsampling,
quality,
speed,
max_threads,
codec,
range_,
tile_rows_log2,
tile_cols_log2,
alpha_premultiplied,
autotiling,
icc_profile or b"",
exif or b"",
exif_orientation,
xmp or b"",
advanced,
)
# Add each frame
frame_idx = 0
frame_duration = 0
cur_idx = im.tell()
is_single_frame = total == 1
try:
for ims in [im] + append_images:
# Get number of frames in this image
nfr = getattr(ims, "n_frames", 1)
for idx in range(nfr):
ims.seek(idx)
# Make sure image mode is supported
frame = ims
rawmode = ims.mode
if ims.mode not in {"RGB", "RGBA"}:
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
frame = ims.convert(rawmode)
# Update frame duration
if isinstance(duration, (list, tuple)):
frame_duration = duration[frame_idx]
else:
frame_duration = duration
# Append the frame to the animation encoder
enc.add(
frame.tobytes("raw", rawmode),
frame_duration,
frame.size,
rawmode,
is_single_frame,
)
# Update frame index
frame_idx += 1
if not save_all:
break
finally:
im.seek(cur_idx)
# Get the final output from the encoder
data = enc.finish()
if data is None:
msg = "cannot write file as AVIF (encoder returned None)"
raise OSError(msg)
fp.write(data)
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
if SUPPORTED:
Image.register_save(AvifImageFile.format, _save)
Image.register_save_all(AvifImageFile.format, _save_all)
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
Image.register_mime(AvifImageFile.format, "image/avif")

View File

@@ -20,29 +20,30 @@
"""
Parse X Bitmap Distribution Format (BDF)
"""
from __future__ import annotations
from typing import BinaryIO
from . import FontFile, Image
bdf_slant = {
"R": "Roman",
"I": "Italic",
"O": "Oblique",
"RI": "Reverse Italic",
"RO": "Reverse Oblique",
"OT": "Other",
}
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()
if not s:
return None
if s[:9] == b"STARTCHAR":
if s.startswith(b"STARTCHAR"):
break
id = s[9:].strip().decode("ascii")
@@ -50,19 +51,18 @@ def bdf_char(f):
props = {}
while True:
s = f.readline()
if not s or s[:6] == b"BITMAP":
if not s or s.startswith(b"BITMAP"):
break
i = s.find(b" ")
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":
if not s or s.startswith(b"ENDCHAR"):
break
bitmap.append(s[:-1])
bitmap = b"".join(bitmap)
bitmap += s[:-1]
# The word BBX
# followed by the width in x (BBw), height in y (BBh),
@@ -92,11 +92,11 @@ 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":
if not s.startswith(b"STARTFONT 2.1"):
msg = "not a valid BDF file"
raise SyntaxError(msg)
@@ -105,7 +105,7 @@ class BdfFontFile(FontFile.FontFile):
while True:
s = fp.readline()
if not s or s[:13] == b"ENDPROPERTIES":
if not s or s.startswith(b"ENDPROPERTIES"):
break
i = s.find(b" ")
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")

View File

@@ -29,10 +29,14 @@ BLP files come in many different flavours:
- 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
@@ -53,11 +57,13 @@ class AlphaEncoding(IntEnum):
DXT5 = 7
def unpack_565(i):
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)
"""
@@ -65,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("<HHI", data, idx)
r0, g0, b0 = unpack_565(color0)
@@ -112,7 +118,7 @@ def decode_dxt1(data, alpha=False):
return ret
def decode_dxt3(data):
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
"""
input: one "row" of data (i.e. will produce 4*width pixels)
"""
@@ -120,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)
@@ -165,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)
"""
@@ -173,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("<BB", block)
@@ -239,8 +245,8 @@ class BLPFormatError(NotImplementedError):
pass
def _accept(prefix):
return prefix[:4] in (b"BLP1", b"BLP2")
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BLP1", b"BLP2"))
class BlpImageFile(ImageFile.ImageFile):
@@ -251,60 +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.fp.seek(5, os.SEEK_CUR)
(self._blp_alpha_depth,) = struct.unpack("<b", self.fp.read(1))
self.fp.seek(2, os.SEEK_CUR)
self._size = struct.unpack("<II", self.fp.read(8))
if self.magic in (b"BLP1", b"BLP2"):
decoder = self.magic.decode()
else:
if not _accept(self.magic):
msg = f"Bad BLP magic {repr(self.magic)}"
raise BLPFormatError(msg)
self._mode = "RGBA" if self._blp_alpha_depth else "RGB"
self.tile = [(decoder, (0, 0) + self.size, 0, (self.mode, 0, 1))]
compression = struct.unpack("<i", self.fp.read(4))[0]
if self.magic == b"BLP1":
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
else:
encoding = struct.unpack("<b", self.fp.read(1))[0]
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
self.fp.seek(1, os.SEEK_CUR) # mips
self._size = struct.unpack("<II", self.fp.read(8))
args: tuple[int, int, bool] | tuple[int, int, bool, int]
if self.magic == b"BLP1":
encoding = struct.unpack("<i", self.fp.read(4))[0]
self.fp.seek(4, os.SEEK_CUR) # subtype
args = (compression, encoding, alpha)
offset = 28
else:
args = (compression, encoding, alpha, alpha_encoding)
offset = 20
decoder = self.magic.decode()
self._mode = "RGBA" if alpha else "RGB"
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
class _BLPBaseDecoder(ImageFile.PyDecoder):
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer):
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
try:
self._read_blp_header()
self._read_header()
self._load()
except struct.error as e:
msg = "Truncated BLP file"
raise OSError(msg) from e
return -1, 0
def _read_blp_header(self):
self.fd.seek(4)
(self._blp_compression,) = struct.unpack("<i", self._safe_read(4))
@abc.abstractmethod
def _load(self) -> None:
pass
(self._blp_encoding,) = struct.unpack("<b", self._safe_read(1))
(self._blp_alpha_depth,) = struct.unpack("<b", self._safe_read(1))
(self._blp_alpha_encoding,) = struct.unpack("<b", self._safe_read(1))
self.fd.seek(1, os.SEEK_CUR) # mips
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))
self.size = struct.unpack("<II", self._safe_read(8))
if isinstance(self, BLP1Decoder):
# Only present for BLP1
(self._blp_encoding,) = struct.unpack("<i", self._safe_read(4))
self.fd.seek(4, os.SEEK_CUR) # subtype
self._blp_offsets = struct.unpack("<16I", self._safe_read(16 * 4))
self._blp_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:
@@ -314,110 +325,115 @@ class _BLPBaseDecoder(ImageFile.PyDecoder):
ret.append((b, g, r, a))
return ret
def _read_bgra(self, palette):
def _read_bgra(
self, palette: list[tuple[int, int, int, int]], alpha: bool
) -> bytearray:
data = bytearray()
_data = BytesIO(self._safe_read(self._blp_lengths[0]))
_data = BytesIO(self._safe_read(self._lengths[0]))
while True:
try:
(offset,) = struct.unpack("<B", _data.read(1))
except struct.error:
break
b, g, r, a = palette[offset]
d = (r, g, b)
if self._blp_alpha_depth:
d: tuple[int, ...] = (r, g, b)
if alpha:
d += (a,)
data.extend(d)
return data
class BLP1Decoder(_BLPBaseDecoder):
def _load(self):
if self._blp_compression == Format.JPEG:
def _load(self) -> 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):
elif self._compression == 1:
if self._encoding in (4, 5):
palette = self._read_palette()
data = self._read_bgra(palette)
self.set_as_raw(bytes(data))
data = self._read_bgra(palette, alpha)
self.set_as_raw(data)
else:
msg = f"Unsupported BLP encoding {repr(self._blp_encoding)}"
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unsupported BLP compression {repr(self._blp_encoding)}"
msg = f"Unsupported BLP compression {repr(self._encoding)}"
raise BLPFormatError(msg)
def _decode_jpeg_stream(self):
def _decode_jpeg_stream(self) -> None:
from .JpegImagePlugin import JpegImageFile
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
jpeg_header = self._safe_read(jpeg_header_size)
self._safe_read(self._blp_offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._blp_lengths[0])
assert self.fd is not None
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
data = self._safe_read(self._lengths[0])
data = jpeg_header + data
data = BytesIO(data)
image = JpegImageFile(data)
image = JpegImageFile(BytesIO(data))
Image._decompression_bomb_check(image.size)
if image.mode == "CMYK":
decoder_name, extents, offset, args = image.tile[0]
image.tile = [(decoder_name, extents, offset, (args[0], "CMYK"))]
r, g, b = image.convert("RGB").split()
image = Image.merge("RGB", (b, g, r))
self.set_as_raw(image.tobytes())
args = image.tile[0].args
assert isinstance(args, tuple)
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
class BLP2Decoder(_BLPBaseDecoder):
def _load(self):
def _load(self) -> None:
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
palette = self._read_palette()
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 == Encoding.UNCOMPRESSED:
data = self._read_bgra(palette)
if self._encoding == Encoding.UNCOMPRESSED:
data = self._read_bgra(palette, alpha)
elif self._blp_encoding == Encoding.DXT:
elif self._encoding == Encoding.DXT:
data = bytearray()
if self._blp_alpha_encoding == AlphaEncoding.DXT1:
linesize = (self.size[0] + 3) // 4 * 8
for yb in range((self.size[1] + 3) // 4):
for d in decode_dxt1(
self._safe_read(linesize), alpha=bool(self._blp_alpha_depth)
):
if self._alpha_encoding == AlphaEncoding.DXT1:
linesize = (self.state.xsize + 3) // 4 * 8
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt1(self._safe_read(linesize), alpha):
data += d
elif self._blp_alpha_encoding == AlphaEncoding.DXT3:
linesize = (self.size[0] + 3) // 4 * 16
for yb in range((self.size[1] + 3) // 4):
elif self._alpha_encoding == AlphaEncoding.DXT3:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt3(self._safe_read(linesize)):
data += d
elif self._blp_alpha_encoding == AlphaEncoding.DXT5:
linesize = (self.size[0] + 3) // 4 * 16
for yb in range((self.size[1] + 3) // 4):
elif self._alpha_encoding == AlphaEncoding.DXT5:
linesize = (self.state.xsize + 3) // 4 * 16
for yb in range((self.state.ysize + 3) // 4):
for d in decode_dxt5(self._safe_read(linesize)):
data += d
else:
msg = f"Unsupported alpha encoding {repr(self._blp_alpha_encoding)}"
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP encoding {repr(self._blp_encoding)}"
msg = f"Unknown BLP encoding {repr(self._encoding)}"
raise BLPFormatError(msg)
else:
msg = f"Unknown BLP compression {repr(self._blp_compression)}"
msg = f"Unknown BLP compression {repr(self._compression)}"
raise BLPFormatError(msg)
self.set_as_raw(bytes(data))
self.set_as_raw(data)
class BLPEncoder(ImageFile.PyEncoder):
_pushes_fd = True
def _write_palette(self):
def _write_palette(self) -> 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]
@@ -426,12 +442,13 @@ class BLPEncoder(ImageFile.PyEncoder):
data += b"\x00" * 4
return data
def encode(self, bufsize):
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))
@@ -444,7 +461,7 @@ class BLPEncoder(ImageFile.PyEncoder):
return len(data), 0, data
def _save(im, fp, filename):
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "P":
msg = "Unsupported BLP image mode"
raise ValueError(msg)
@@ -452,17 +469,23 @@ def _save(im, fp, filename):
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("<i", 1)) # Uncompressed or DirectX compression
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
fp.write(struct.pack("<b", 1 if im.palette.mode == "RGBA" else 0))
fp.write(struct.pack("<b", 0)) # alpha encoding
fp.write(struct.pack("<b", 0)) # mips
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
if magic == b"BLP1":
fp.write(struct.pack("<L", alpha_depth))
else:
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
fp.write(struct.pack("<b", alpha_depth))
fp.write(struct.pack("<b", 0)) # alpha encoding
fp.write(struct.pack("<b", 0)) # mips
fp.write(struct.pack("<II", *im.size))
if magic == b"BLP1":
fp.write(struct.pack("<i", 5))
fp.write(struct.pack("<i", 0))
ImageFile._save(im, fp, [("BLP", (0, 0) + im.size, 0, im.mode)])
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)

View File

@@ -22,9 +22,10 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO, Any
from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
@@ -47,13 +48,15 @@ BIT2MODE = {
32: ("RGB", "BGRX"),
}
def _accept(prefix):
return prefix[:2] == b"BM"
USE_RAW_ALPHA = False
def _dib_accept(prefix):
return i32(prefix) in [12, 40, 64, 108, 124]
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"BM")
def _dib_accept(prefix: bytes) -> bool:
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
# =============================================================================
@@ -71,31 +74,41 @@ 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)
# read bmp header size @offset 14 (this is part of the header size)
file_info = {"header_size": i32(read(4)), "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)
@@ -115,12 +128,16 @@ class BmpImageFile(ImageFile.ImageFile):
)
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 +149,12 @@ class BmpImageFile(ImageFile.ImageFile):
# 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"],
@@ -151,33 +172,39 @@ class BmpImageFile(ImageFile.ImageFile):
# ------------------ 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:
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)
decoder_name = "raw"
if file_info["compression"] == self.BITFIELDS:
SUPPORTED = {
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
32: [
(0xFF0000, 0xFF00, 0xFF, 0x0),
(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),
],
24: [(0xFF0000, 0xFF00, 0xFF)],
@@ -186,9 +213,11 @@ class BmpImageFile(ImageFile.ImageFile):
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",
@@ -199,12 +228,14 @@ class BmpImageFile(ImageFile.ImageFile):
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
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:
msg = "Unsupported BMP bitfields layout"
@@ -212,10 +243,15 @@ class BmpImageFile(ImageFile.ImageFile):
else:
msg = "Unsupported BMP bitfields layout"
raise OSError(msg)
elif file_info["compression"] == self.RAW:
if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
if file_info["bits"] == 32 and (
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
):
raw_mode, self._mode = "BGRA", "RGBA"
elif file_info["compression"] in (self.RLE8, self.RLE4):
elif file_info["compression"] in (
self.COMPRESSIONS["RLE8"],
self.COMPRESSIONS["RLE4"],
):
decoder_name = "bmp_rle"
else:
msg = f"Unsupported BMP compression ({file_info['compression']})"
@@ -228,23 +264,24 @@ class BmpImageFile(ImageFile.ImageFile):
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:
# ------- 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:
@@ -255,14 +292,15 @@ class BmpImageFile(ImageFile.ImageFile):
# ---------------------------- Finally set the tile data for the plugin
self.info["compression"] = file_info["compression"]
args = [raw_mode]
args: list[Any] = [raw_mode]
if decoder_name == "bmp_rle":
args.append(file_info["compression"] == self.RLE4)
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 = [
(
ImageFile._Tile(
decoder_name,
(0, 0, file_info["width"], file_info["height"]),
offset or self.fp.tell(),
@@ -270,7 +308,7 @@ class BmpImageFile(ImageFile.ImageFile):
)
]
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)
@@ -287,11 +325,13 @@ class BmpImageFile(ImageFile.ImageFile):
class BmpRleDecoder(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
rle4 = self.args[1]
data = bytearray()
x = 0
while len(data) < self.state.xsize * self.state.ysize:
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:
@@ -351,7 +391,7 @@ class BmpRleDecoder(ImageFile.PyDecoder):
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]))
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
return -1, 0
@@ -362,7 +402,7 @@ class DibImageFile(BmpImageFile):
format = "DIB"
format_description = "Windows Bitmap"
def _open(self):
def _open(self) -> None:
self._bitmap()
@@ -380,11 +420,13 @@ SAVE = {
}
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:
@@ -396,16 +438,16 @@ def _save(im, fp, filename, bitmap_header=True):
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))
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
elif im.mode == "L":
palette = b"".join(o8(i) * 4 for i in range(256))
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
elif im.mode == "P":
palette = im.im.getpalette("RGB", "BGRX")
colors = len(palette) // 4
@@ -446,7 +488,9 @@ def _save(im, fp, filename, bitmap_header=True):
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))]
)
#

View File

@@ -8,13 +8,17 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
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,22 +32,20 @@ def register_handler(handler):
# Image adapter
def _accept(prefix):
return prefix[:4] == b"BUFR" or prefix[:4] == b"ZCZC"
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"BUFR", b"ZCZC"))
class BufrStubImageFile(ImageFile.StubImageFile):
format = "BUFR"
format_description = "BUFR"
def _open(self):
offset = self.fp.tell()
def _open(self) -> None:
if not _accept(self.fp.read(4)):
msg = "Not a BUFR file"
raise SyntaxError(msg)
self.fp.seek(offset)
self.fp.seek(-4, os.SEEK_CUR)
# make something up
self._mode = "F"
@@ -53,11 +55,11 @@ class BufrStubImageFile(ImageFile.StubImageFile):
if loader:
loader.open(self)
def _load(self):
def _load(self) -> ImageFile.StubHandler | None:
return _handler
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"):
msg = "BUFR save handler not installed"
raise OSError(msg)

View File

@@ -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 @@ class ContainerIO:
: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 @@ class ContainerIO:
##
# 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 @@ class ContainerIO:
: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 @@ class ContainerIO:
# 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 @@ class ContainerIO:
"""
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 @@ class ContainerIO:
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()

View File

@@ -15,6 +15,8 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import BmpImagePlugin, Image
from ._binary import i16le as i16
from ._binary import i32le as i32
@@ -23,8 +25,8 @@ from ._binary import i32le as i32
# --------------------------------------------------------------------
def _accept(prefix):
return prefix[:4] == b"\0\0\2\0"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\0\0\2\0")
##
@@ -35,7 +37,8 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
format = "CUR"
format_description = "Windows Cursor"
def _open(self):
def _open(self) -> None:
assert self.fp is not None
offset = self.fp.tell()
# check magic
@@ -61,10 +64,7 @@ class CurImageFile(BmpImagePlugin.BmpImageFile):
# 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 = [self.tile[0]._replace(extents=(0, 0) + self.size)]
#

View File

@@ -20,15 +20,17 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image
from ._binary import i32le as i32
from ._util import DeferredError
from .PcxImagePlugin import PcxImageFile
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
def _accept(prefix):
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 4 and i32(prefix) == MAGIC
@@ -41,7 +43,7 @@ class DcxImageFile(PcxImageFile):
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):
@@ -57,20 +59,22 @@ class DcxImageFile(PcxImageFile):
self._offset.append(offset)
self._fp = self.fp
self.frame = None
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
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.frame = frame
self.fp = self._fp
self.fp.seek(self._offset[frame])
PcxImageFile._open(self)
def tell(self):
def tell(self) -> int:
return self.frame

View File

@@ -1,118 +1,338 @@
"""
A Pillow loader for .dds files (S3TC-compressed aka DXTC)
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
Jerome Leclanche <jerome@leclan.ch>
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, 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
class DDSD(IntFlag):
CAPS = 0x1
HEIGHT = 0x2
WIDTH = 0x4
PITCH = 0x8
PIXELFORMAT = 0x1000
MIPMAPCOUNT = 0x20000
LINEARSIZE = 0x80000
DEPTH = 0x800000
# DDS caps
DDSCAPS_COMPLEX = 0x8
DDSCAPS_TEXTURE = 0x1000
DDSCAPS_MIPMAP = 0x400000
class DDSCAPS(IntFlag):
COMPLEX = 0x8
TEXTURE = 0x1000
MIPMAP = 0x400000
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
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_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
DXT1_FOURCC = 0x31545844
# DXT3
DXT3_FOURCC = 0x33545844
# DXT5
DXT5_FOURCC = 0x35545844
class DDPF(IntFlag):
ALPHAPIXELS = 0x1
ALPHA = 0x2
FOURCC = 0x4
PALETTEINDEXED8 = 0x20
RGB = 0x40
LUMINANCE = 0x20000
# dxgiformat.h
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
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_BC6H_UF16 = 95
DXGI_FORMAT_BC6H_SF16 = 96
DXGI_FORMAT_BC7_TYPELESS = 97
DXGI_FORMAT_BC7_UNORM = 98
DXGI_FORMAT_BC7_UNORM_SRGB = 99
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):
def _open(self) -> None:
if not _accept(self.fp.read(4)):
msg = "not a DDS file"
raise SyntaxError(msg)
@@ -124,172 +344,281 @@ class DdsImageFile(ImageFile.ImageFile):
if len(header_bytes) != 120:
msg = f"Incomplete header: {len(header_bytes)} bytes"
raise OSError(msg)
header = BytesIO(header_bytes)
header = io.BytesIO(header_bytes)
flags, height, width = struct.unpack("<3I", header.read(12))
self._size = (width, height)
self._mode = "RGBA"
extents = (0, 0) + self.size
pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
struct.unpack("<11I", header.read(44)) # reserved
# pixel format
pfsize, pfflags = struct.unpack("<2I", header.read(8))
fourcc = header.read(4)
(bitcount,) = struct.unpack("<I", header.read(4))
masks = struct.unpack("<4I", header.read(16))
if pfflags & DDPF_LUMINANCE:
# Texture contains uncompressed L or LA data
if pfflags & DDPF_ALPHAPIXELS:
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header.read(16))
n = 0
rawmode = None
if pfflags & DDPF.RGB:
# Texture contains uncompressed RGB data
if pfflags & DDPF.ALPHAPIXELS:
self._mode = "RGBA"
mask_count = 4
else:
self._mode = "RGB"
mask_count = 3
masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
return
elif pfflags & DDPF.LUMINANCE:
if bitcount == 8:
self._mode = "L"
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
self._mode = "LA"
else:
self._mode = "L"
self.tile = [("raw", (0, 0) + self.size, 0, (self.mode, 0, 1))]
elif pfflags & DDPF_RGB:
# Texture contains uncompressed RGB data
masks = {mask: ["R", "G", "B", "A"][i] for i, mask in enumerate(masks)}
rawmode = ""
if pfflags & DDPF_ALPHAPIXELS:
rawmode += masks[0xFF000000]
else:
self._mode = "RGB"
rawmode += masks[0xFF0000] + masks[0xFF00] + masks[0xFF]
self.tile = [("raw", (0, 0) + self.size, 0, (rawmode[::-1], 0, 1))]
elif pfflags & DDPF_PALETTEINDEXED8:
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
raise OSError(msg)
elif pfflags & DDPF.PALETTEINDEXED8:
self._mode = "P"
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
self.tile = [("raw", (0, 0) + self.size, 0, "L")]
else:
data_start = header_size + 4
n = 0
if fourcc == b"DXT1":
self.palette.mode = "RGBA"
elif pfflags & DDPF.FOURCC:
offset = header_size + 4
if fourcc == D3DFMT.DXT1:
self._mode = "RGBA"
self.pixel_format = "DXT1"
n = 1
elif fourcc == b"DXT3":
elif fourcc == D3DFMT.DXT3:
self._mode = "RGBA"
self.pixel_format = "DXT3"
n = 2
elif fourcc == b"DXT5":
elif fourcc == D3DFMT.DXT5:
self._mode = "RGBA"
self.pixel_format = "DXT5"
n = 3
elif fourcc == b"ATI1":
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
self._mode = "L"
elif fourcc in (b"ATI2", b"BC5U"):
self.pixel_format = "BC5"
n = 5
elif fourcc == D3DFMT.BC5S:
self._mode = "RGB"
elif fourcc == b"BC5S":
self.pixel_format = "BC5S"
n = 5
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
self._mode = "RGB"
elif fourcc == b"DX10":
data_start += 20
self.pixel_format = "BC5"
n = 5
elif fourcc == D3DFMT.DX10:
offset += 20
# ignoring flags which pertain to volume textures and cubemaps
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
self.fp.read(16)
if dxgi_format in (DXGI_FORMAT_BC5_TYPELESS, DXGI_FORMAT_BC5_UNORM):
if dxgi_format in (
DXGI_FORMAT.BC1_UNORM,
DXGI_FORMAT.BC1_TYPELESS,
):
self._mode = "RGBA"
self.pixel_format = "BC1"
n = 1
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC2"
n = 2
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
self._mode = "RGBA"
self.pixel_format = "BC3"
n = 3
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
self._mode = "L"
self.pixel_format = "BC4"
n = 4
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
self._mode = "RGB"
self.pixel_format = "BC5"
n = 5
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
self._mode = "RGB"
elif dxgi_format == DXGI_FORMAT_BC5_SNORM:
self.pixel_format = "BC5S"
n = 5
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
self._mode = "RGB"
elif dxgi_format == DXGI_FORMAT_BC6H_UF16:
self.pixel_format = "BC6H"
n = 6
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
self._mode = "RGB"
elif dxgi_format == DXGI_FORMAT_BC6H_SF16:
self.pixel_format = "BC6HS"
n = 6
self._mode = "RGB"
elif dxgi_format in (DXGI_FORMAT_BC7_TYPELESS, DXGI_FORMAT_BC7_UNORM):
self.pixel_format = "BC7"
n = 7
elif dxgi_format == DXGI_FORMAT_BC7_UNORM_SRGB:
self.pixel_format = "BC7"
self.info["gamma"] = 1 / 2.2
n = 7
elif dxgi_format in (
DXGI_FORMAT_R8G8B8A8_TYPELESS,
DXGI_FORMAT_R8G8B8A8_UNORM,
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB,
DXGI_FORMAT.BC7_TYPELESS,
DXGI_FORMAT.BC7_UNORM,
DXGI_FORMAT.BC7_UNORM_SRGB,
):
self.tile = [("raw", (0, 0) + self.size, 0, ("RGBA", 0, 1))]
if dxgi_format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB:
self._mode = "RGBA"
self.pixel_format = "BC7"
n = 7
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
elif dxgi_format in (
DXGI_FORMAT.R8G8B8A8_TYPELESS,
DXGI_FORMAT.R8G8B8A8_UNORM,
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
):
self._mode = "RGBA"
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
self.info["gamma"] = 1 / 2.2
return
else:
msg = f"Unimplemented DXGI format {dxgi_format}"
raise NotImplementedError(msg)
else:
msg = f"Unimplemented pixel format {repr(fourcc)}"
raise NotImplementedError(msg)
else:
msg = f"Unknown pixel format flags {pfflags}"
raise NotImplementedError(msg)
if n:
self.tile = [
("bcn", (0, 0) + self.size, data_start, (n, self.pixel_format))
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
]
else:
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
def load_seek(self, pos):
def load_seek(self, pos: int) -> None:
pass
def _save(im, fp, filename):
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)
rawmode = im.mode
masks = [0xFF0000, 0xFF00, 0xFF]
if im.mode in ("L", "LA"):
pixel_flags = DDPF_LUMINANCE
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
pixel_format = im.encoderinfo.get("pixel_format")
args: tuple[int] | str
if pixel_format:
codec_name = "bcn"
flags |= DDSD.LINEARSIZE
pitch = (im.width + 3) * 4
rgba_mask = [0, 0, 0, 0]
pixel_flags = DDPF.FOURCC
if pixel_format == "DXT1":
fourcc = D3DFMT.DXT1
args = (1,)
elif pixel_format == "DXT3":
fourcc = D3DFMT.DXT3
args = (2,)
elif pixel_format == "DXT5":
fourcc = D3DFMT.DXT5
args = (3,)
else:
fourcc = D3DFMT.DX10
if pixel_format == "BC2":
args = (2,)
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
elif pixel_format == "BC3":
args = (3,)
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
elif pixel_format == "BC5":
args = (5,)
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
if im.mode != "RGB":
msg = "only RGB mode can be written as BC5"
raise OSError(msg)
else:
msg = f"cannot write pixel format {pixel_format}"
raise OSError(msg)
else:
pixel_flags = DDPF_RGB
rawmode = rawmode[::-1]
if im.mode in ("LA", "RGBA"):
pixel_flags |= DDPF_ALPHAPIXELS
masks.append(0xFF000000)
codec_name = "raw"
flags |= DDSD.PITCH
pitch = (im.width * bitcount + 7) // 8
bitcount = len(masks) * 8
while len(masks) < 4:
masks.append(0)
alpha = im.mode[-1] == "A"
if im.mode[0] == "L":
pixel_flags = DDPF.LUMINANCE
args = im.mode
if alpha:
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
else:
pixel_flags = DDPF.RGB
args = 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)
fourcc = D3DFMT.UNKNOWN
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 * bitcount + 7) // 8) # pitch
+ o32(0) # depth
+ o32(0) # mipmaps
+ o32(0) * 11 # reserved
+ o32(32) # pfsize
+ o32(pixel_flags) # pfflags
+ o32(0) # fourcc
+ o32(bitcount) # bitcount
+ b"".join(o32(mask) for mask in masks) # rgbabitmask
+ 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, fourcc, 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, (rawmode, 0, 1))])
if fourcc == D3DFMT.DX10:
fp.write(
# dxgi_format, 2D resource, misc, array size, straight alpha
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
)
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
def _accept(prefix):
return prefix[:4] == b"DDS "
def _accept(prefix: bytes) -> bool:
return prefix.startswith(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")

View File

@@ -19,6 +19,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
@@ -26,10 +27,10 @@ import re
import subprocess
import sys
import tempfile
from typing import IO
from . import Image, ImageFile
from ._binary import i32le as i32
from ._deprecate import deprecate
# --------------------------------------------------------------------
@@ -37,11 +38,11 @@ from ._deprecate import deprecate
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
gs_binary = None
gs_binary: str | bool | None = None
gs_windows_binary = None
def has_ghostscript():
def has_ghostscript() -> bool:
global gs_binary, gs_windows_binary
if gs_binary is None:
if sys.platform.startswith("win"):
@@ -64,27 +65,32 @@ def has_ghostscript():
return gs_binary is not False
def Ghostscript(tile, size, fp, scale=1, transparency=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)
@@ -115,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_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
@@ -145,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)
@@ -155,50 +168,11 @@ 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.
This class is no longer used internally, but kept for backwards compatibility.
"""
def __init__(self, fp):
deprecate(
"PSFile",
11,
action="If you need the functionality of this class "
"you will need to implement it yourself.",
)
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):
return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5)
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"%!PS") or (
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
)
##
@@ -214,14 +188,18 @@ 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)
# go to offset - start of "%!PS"
self.fp.seek(offset)
self._mode = "RGB"
self._size = None
# 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)
@@ -230,7 +208,12 @@ class EpsImageFile(ImageFile.ImageFile):
reading_trailer_comments = False
trailer_reached = False
def check_required_header_comments():
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)
@@ -238,41 +221,39 @@ class EpsImageFile(ImageFile.ImageFile):
msg = 'EPS header missing "%%BoundingBox" comment'
raise SyntaxError(msg)
def _read_comment(s):
nonlocal reading_trailer_comments
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 m:
k, v = m.group(1, 2)
self.info[k] = v
if k == "BoundingBox":
if v == "(atend)":
reading_trailer_comments = True
elif not self._size 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.
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
return True
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
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
@@ -312,11 +293,11 @@ class EpsImageFile(ImageFile.ImageFile):
continue
s = str(bytes_mv[:bytes_read], "latin-1")
if not _read_comment(s):
if not read_comment(s):
m = field.match(s)
if m:
k = m.group(1)
if k[:8] == "PS-Adobe":
if k.startswith("PS-Adobe"):
self.info["PS-Adobe"] = k[9:]
else:
self.info[k] = ""
@@ -331,6 +312,12 @@ class EpsImageFile(ImageFile.ImageFile):
# 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
@@ -356,29 +343,39 @@ class EpsImageFile(ImageFile.ImageFile):
else:
break
self._size = columns, rows
return
# 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
elif trailer_reached and reading_trailer_comments:
# Load EPS trailer
# if this line starts with "%%EOF",
# then we've reached the end of the file
if bytes_mv[:5] == b"%%EOF":
break
s = str(bytes_mv[:bytes_read], "latin-1")
_read_comment(s)
read_comment(s)
elif bytes_mv[:9] == b"%%Trailer":
trailer_reached = True
elif bytes_mv[:14] == b"%%BeginBinary:":
bytecount = int(byte_arr[14:bytes_read])
self.fp.seek(bytecount, os.SEEK_CUR)
bytes_read = 0
check_required_header_comments()
if not self._size:
# 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)
def _find_offset(self, fp):
# 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":
@@ -401,7 +398,9 @@ class EpsImageFile(ImageFile.ImageFile):
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 self.tile:
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
@@ -410,7 +409,7 @@ class EpsImageFile(ImageFile.ImageFile):
self.tile = []
return Image.Image.load(self)
def load_seek(self, *args, **kwargs):
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
@@ -419,7 +418,7 @@ class EpsImageFile(ImageFile.ImageFile):
# --------------------------------------------------------------------
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
@@ -460,7 +459,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")

View File

@@ -13,6 +13,7 @@
This module provides constants and clear-text names for various
well-known EXIF tags.
"""
from __future__ import annotations
from enum import IntEnum
@@ -302,38 +303,38 @@ TAGS = {
class GPS(IntEnum):
GPSVersionID = 0
GPSLatitudeRef = 1
GPSLatitude = 2
GPSLongitudeRef = 3
GPSLongitude = 4
GPSAltitudeRef = 5
GPSAltitude = 6
GPSTimeStamp = 7
GPSSatellites = 8
GPSStatus = 9
GPSMeasureMode = 10
GPSDOP = 11
GPSSpeedRef = 12
GPSSpeed = 13
GPSTrackRef = 14
GPSTrack = 15
GPSImgDirectionRef = 16
GPSImgDirection = 17
GPSMapDatum = 18
GPSDestLatitudeRef = 19
GPSDestLatitude = 20
GPSDestLongitudeRef = 21
GPSDestLongitude = 22
GPSDestBearingRef = 23
GPSDestBearing = 24
GPSDestDistanceRef = 25
GPSDestDistance = 26
GPSProcessingMethod = 27
GPSAreaInformation = 28
GPSDateStamp = 29
GPSDifferential = 30
GPSHPositioningError = 31
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."""
@@ -341,40 +342,41 @@ GPSTAGS = {i.value: i.name for i in GPS}
class Interop(IntEnum):
InteropIndex = 1
InteropVersion = 2
RelatedImageFileFormat = 4096
RelatedImageWidth = 4097
RleatedImageHeight = 4098
InteropIndex = 0x0001
InteropVersion = 0x0002
RelatedImageFileFormat = 0x1000
RelatedImageWidth = 0x1001
RelatedImageHeight = 0x1002
class IFD(IntEnum):
Exif = 34665
GPSInfo = 34853
Makernote = 37500
Interop = 40965
Exif = 0x8769
GPSInfo = 0x8825
MakerNote = 0x927C
Makernote = 0x927C # Deprecated
Interop = 0xA005
IFD1 = -1
class LightSource(IntEnum):
Unknown = 0
Daylight = 1
Fluorescent = 2
Tungsten = 3
Flash = 4
Fine = 9
Cloudy = 10
Shade = 11
DaylightFluorescent = 12
DayWhiteFluorescent = 13
CoolWhiteFluorescent = 14
WhiteFluorescent = 15
StandardLightA = 17
StandardLightB = 18
StandardLightC = 19
D55 = 20
D65 = 21
D75 = 22
D50 = 23
ISO = 24
Other = 255
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

View File

@@ -8,30 +8,52 @@
#
# 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):
return prefix[:6] == b"SIMPLE"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"SIMPLE")
class FitsImageFile(ImageFile.ImageFile):
format = "FITS"
format_description = "FITS"
def _open(self):
headers = {}
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 == b"END":
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()
@@ -40,34 +62,91 @@ class FitsImageFile(ImageFile.ImageFile):
raise SyntaxError(msg)
headers[keyword] = value
naxis = int(headers[b"NAXIS"])
if naxis == 0:
if not decoder_name:
msg = "No image data"
raise ValueError(msg)
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"])
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"
# rawmode = "I;16S"
self._mode = "I;16"
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"
offset = math.ceil(self.fp.tell() / 2880) * 2880
self.tile = [("raw", (0, 0) + self.size, offset, (self.mode, 0, -1))]
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"])

View File

@@ -14,6 +14,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
@@ -21,14 +22,15 @@ from . import Image, ImageFile, ImagePalette
from ._binary import i16le as i16
from ._binary import i32le as i32
from ._binary import o8
from ._util import DeferredError
#
# decoder
def _accept(prefix):
def _accept(prefix: bytes) -> bool:
return (
len(prefix) >= 6
len(prefix) >= 16
and i16(prefix, 4) in [0xAF11, 0xAF12]
and i16(prefix, 14) in [0, 3] # flags
)
@@ -44,10 +46,16 @@ class FliImageFile(ImageFile.ImageFile):
format_description = "Autodesk FLI/FLC Animation"
_close_exclusive_fp_after_loading = False
def _open(self):
def _open(self) -> None:
# HEAD
assert self.fp is not None
s = self.fp.read(128)
if not (_accept(s) and s[20:22] == b"\x00\x00"):
if not (
_accept(s)
and s[20:22] == b"\x00" * 2
and s[42:80] == b"\x00" * 38
and s[88:] == b"\x00" * 40
):
msg = "not an FLI/FLC file"
raise SyntaxError(msg)
@@ -75,13 +83,13 @@ class FliImageFile(ImageFile.ImageFile):
if i16(s, 4) == 0xF100:
# prefix chunk; ignore it
self.__offset = self.__offset + i32(s)
self.fp.seek(self.__offset + i32(s))
s = self.fp.read(16)
if i16(s, 4) == 0xF1FA:
# look for palette chunk
number_of_subchunks = i16(s, 6)
chunk_size = None
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)
@@ -94,8 +102,9 @@ class FliImageFile(ImageFile.ImageFile):
if not chunk_size:
break
palette = [o8(r) + o8(g) + o8(b) for (r, g, b) in palette]
self.palette = ImagePalette.raw("RGB", b"".join(palette))
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
@@ -103,10 +112,11 @@ class FliImageFile(ImageFile.ImageFile):
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
assert self.fp is not None
for e in range(i16(self.fp.read(2))):
s = self.fp.read(2)
i = i + s[0]
@@ -121,7 +131,7 @@ class FliImageFile(ImageFile.ImageFile):
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:
@@ -130,7 +140,9 @@ class FliImageFile(ImageFile.ImageFile):
for f in range(self.__frame + 1, frame + 1):
self._seek(f)
def _seek(self, frame):
def _seek(self, frame: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
if frame == 0:
self.__frame = -1
self._fp.seek(self.__rewind)
@@ -150,16 +162,17 @@ class FliImageFile(ImageFile.ImageFile):
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

View File

@@ -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,16 +36,32 @@ def puti16(fp, values):
class FontFile:
"""Base class for raster font file handlers."""
bitmap = None
bitmap: Image.Image | None = None
def __init__(self):
self.info = {}
self.glyph = [None] * 256
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):
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:
@@ -51,7 +70,7 @@ class FontFile:
# 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])
@@ -65,20 +84,22 @@ class FontFile:
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:
@@ -89,12 +110,15 @@ class FontFile:
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
@@ -105,6 +129,6 @@ class FontFile:
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])

View File

@@ -14,6 +14,8 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import olefile
from . import Image, ImageFile
@@ -39,8 +41,8 @@ MODES = {
# --------------------------------------------------------------------
def _accept(prefix):
return prefix[:8] == olefile.MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
@@ -51,7 +53,7 @@ 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
@@ -62,13 +64,14 @@ class FpxImageFile(ImageFile.ImageFile):
msg = "not an FPX file; invalid OLE file"
raise SyntaxError(msg) from e
if self.ole.root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
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 @@ class FpxImageFile(ImageFile.ImageFile):
# 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,16 +102,14 @@ class FpxImageFile(ImageFile.ImageFile):
s = prop[0x2000002 | id]
colors = []
bands = i32(s, 4)
if bands > 4:
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
msg = "Invalid number of bands"
raise OSError(msg)
for i in range(bands):
# note: for now, we ignore the "uncalibrated" flag
colors.append(i32(s, 8 + i * 4) & 0x7FFFFFFF)
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 = {}
@@ -117,7 +120,7 @@ class FpxImageFile(ImageFile.ImageFile):
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
@@ -163,18 +166,18 @@ class FpxImageFile(ImageFile.ImageFile):
if compression == 0:
self.tile.append(
(
ImageFile._Tile(
"raw",
(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, x1, y1),
i32(s, i) + 28,
@@ -202,7 +205,7 @@ class FpxImageFile(ImageFile.ImageFile):
jpegmode = rawmode
self.tile.append(
(
ImageFile._Tile(
"jpeg",
(x, y, x1, y1),
i32(s, i) + 28,
@@ -227,19 +230,20 @@ class FpxImageFile(ImageFile.ImageFile):
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):
def close(self) -> None:
self.ole.close()
super().close()
def __exit__(self, *args):
def __exit__(self, *args: object) -> None:
self.ole.close()
super().__exit__()

View File

@@ -51,6 +51,8 @@ bytes for that mipmap level.
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
@@ -69,7 +71,7 @@ class FtexImageFile(ImageFile.ImageFile):
format = "FTEX"
format_description = "Texture File Format (IW2:EOC)"
def _open(self):
def _open(self) -> None:
if not _accept(self.fp.read(4)):
msg = "not an FTEX file"
raise SyntaxError(msg)
@@ -77,8 +79,6 @@ class FtexImageFile(ImageFile.ImageFile):
self._size = struct.unpack("<2i", self.fp.read(8))
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
self._mode = "RGB"
# Only support single-format files.
# I don't know of any multi-format file.
assert format_count == 1
@@ -91,9 +91,10 @@ class FtexImageFile(ImageFile.ImageFile):
if format == Format.DXT1:
self._mode = "RGBA"
self.tile = [("bcn", (0, 0) + self.size, 0, 1)]
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
elif format == Format.UNCOMPRESSED:
self.tile = [("raw", (0, 0) + self.size, 0, ("RGB", 0, 1))]
self._mode = "RGB"
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
else:
msg = f"Invalid texture compression format: {repr(format)}"
raise ValueError(msg)
@@ -101,12 +102,12 @@ class FtexImageFile(ImageFile.ImageFile):
self.fp.close()
self.fp = BytesIO(data)
def load_seek(self, pos):
def load_seek(self, pos: int) -> None:
pass
def _accept(prefix):
return prefix[:4] == MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(MAGIC)
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)

View File

@@ -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)
@@ -40,7 +41,7 @@ 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))
if header_size < 20:
msg = "not a GIMP brush"
@@ -53,7 +54,7 @@ class GbrImageFile(ImageFile.ImageFile):
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:
if width == 0 or height == 0:
msg = "not a GIMP brush"
raise SyntaxError(msg)
if color_depth not in (1, 4):
@@ -70,7 +71,7 @@ class GbrImageFile(ImageFile.ImageFile):
raise SyntaxError(msg)
self.info["spacing"] = i32(self.fp.read(4))
comment = self.fp.read(comment_length)[:-1]
self.info["comment"] = self.fp.read(comment_length)[:-1]
if color_depth == 1:
self._mode = "L"
@@ -79,16 +80,14 @@ class GbrImageFile(ImageFile.ImageFile):
self._size = width, height
self.info["comment"] = comment
# Image might not be small
Image._decompression_bomb_check(self.size)
# Data is an uncompressed block of w * h * bytes/pixel
self._data_size = width * height * color_depth
def load(self):
if not self.im:
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)

View File

@@ -25,11 +25,14 @@
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,15 +46,17 @@ 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 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 = "P"
self._size = i16(s, 2), i16(s, 4)
true_color = s[6]
@@ -63,20 +68,20 @@ class GdImageFile(ImageFile.ImageFile):
self.info["transparency"] = tindex
self.palette = ImagePalette.raw(
"XBGR", s[7 + true_color_offset + 4 : 7 + true_color_offset + 4 + 256 * 4]
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
)
self.tile = [
(
ImageFile._Tile(
"raw",
(0, 0) + self.size,
7 + true_color_offset + 4 + 256 * 4,
("L", 0, 1),
7 + true_color_offset + 6 + 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.

View File

@@ -23,17 +23,36 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import itertools
import math
import os
import subprocess
from enum import IntEnum
from functools import cached_property
from typing import Any, NamedTuple, cast
from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence
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
from ._util import DeferredError
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO, Literal
from . import _imaging
from ._typing import Buffer
class LoadingStrategy(IntEnum):
@@ -51,8 +70,8 @@ LOADING_STRATEGY = LoadingStrategy.RGB_AFTER_FIRST
# Identify/read GIF files
def _accept(prefix):
return prefix[:6] in [b"GIF87a", b"GIF89a"]
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"GIF87a", b"GIF89a"))
##
@@ -67,19 +86,19 @@ class GifImageFile(ImageFile.ImageFile):
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 _is_palette_needed(self, p):
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):
def _open(self) -> None:
# Screen
s = self.fp.read(13)
if not _accept(s):
@@ -88,7 +107,6 @@ class GifImageFile(ImageFile.ImageFile):
self.info["version"] = s[:6]
self._size = i16(s, 6), i16(s, 8)
self.tile = []
flags = s[10]
bits = (flags & 7) + 1
@@ -103,12 +121,11 @@ class GifImageFile(ImageFile.ImageFile):
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:
@@ -119,30 +136,29 @@ class GifImageFile(ImageFile.ImageFile):
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()
if current:
self._is_animated = True
else:
try:
self._seek(1, False)
self._is_animated = True
except EOFError:
self._is_animated = False
@cached_property
def is_animated(self) -> bool:
if self._n_frames is not None:
return self._n_frames != 1
self.seek(current)
return self._is_animated
current = self.tell()
if current:
return True
def seek(self, frame):
try:
self._seek(1, False)
is_animated = True
except EOFError:
is_animated = False
self.seek(current)
return is_animated
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if frame < self.__frame:
self.im = None
self._im = None
self._seek(0)
last_frame = self.__frame
@@ -154,11 +170,13 @@ class GifImageFile(ImageFile.ImageFile):
msg = "no more images in GIF file"
raise EOFError(msg) from e
def _seek(self, frame, update_image=True):
def _seek(self, frame: int, update_image: bool = True) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
if frame == 0:
# rewind
self.__offset = 0
self.dispose = None
self.dispose: _imaging.ImagingCore | None = None
self.__frame = -1
self._fp.seek(self.__rewind)
self.disposal_method = 0
@@ -183,11 +201,12 @@ class GifImageFile(ImageFile.ImageFile):
s = self.fp.read(1)
if not s or s == b";":
raise EOFError
msg = "no more images in GIF file"
raise EOFError(msg)
palette = None
palette: ImagePalette.ImagePalette | Literal[False] | None = None
info = {}
info: dict[str, Any] = {}
frame_transparency = None
interlace = None
frame_dispose_extent = None
@@ -203,7 +222,7 @@ class GifImageFile(ImageFile.ImageFile):
#
s = self.fp.read(1)
block = self.data()
if s[0] == 249:
if s[0] == 249 and block is not None:
#
# graphic control extension
#
@@ -239,14 +258,14 @@ class GifImageFile(ImageFile.ImageFile):
info["comment"] = comment
s = None
continue
elif s[0] == 255 and frame == 0:
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":
if block.startswith(b"NETSCAPE2.0"):
block = self.data()
if len(block) >= 3 and block[0] == 1:
if block and len(block) >= 3 and block[0] == 1:
self.info["loop"] = i16(block, 1)
while self.data():
pass
@@ -280,15 +299,11 @@ class GifImageFile(ImageFile.ImageFile):
bits = self.fp.read(1)[0]
self.__offset = self.fp.tell()
break
else:
pass
# raise OSError, "illegal GIF tag `%x`" % s[0]
s = None
if interlace is None:
# self._fp = None
raise EOFError
msg = "image not found in GIF frame"
raise EOFError(msg)
self.__frame = frame
if not update_image:
@@ -310,18 +325,20 @@ class GifImageFile(ImageFile.ImageFile):
else:
self._mode = "L"
if not palette and self.global_palette:
if palette:
self.palette = palette
elif self.global_palette:
from copy import copy
palette = copy(self.global_palette)
self.palette = palette
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
):
self.pyaccess = None
if "transparency" in self.info:
self.im.putpalettealpha(self.info["transparency"], 0)
self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
@@ -331,58 +348,63 @@ class GifImageFile(ImageFile.ImageFile):
self._mode = "RGB"
self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
def _rgb(color):
def _rgb(color: int) -> tuple[int, int, int]:
if self._frame_palette:
color = tuple(self._frame_palette.palette[color * 3 : color * 3 + 3])
if color * 3 + 3 > len(self._frame_palette.palette):
color = 0
return cast(
tuple[int, int, int],
tuple(self._frame_palette.palette[color * 3 : color * 3 + 3]),
)
else:
color = (color, color, color)
return color
return (color, color, color)
self.dispose_extent = frame_dispose_extent
try:
if self.disposal_method < 2:
# do not dispose or none specified
self.dispose = None
elif self.disposal_method == 2:
# replace with background colour
self.dispose = None
self.dispose_extent: tuple[int, int, int, int] | None = 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)
# by convention, attempt to use transparency first
dispose_mode = "P"
color = frame_transparency
if self.mode in ("RGB", "RGBA"):
dispose_mode = "RGBA"
color = _rgb(frame_transparency) + (0,)
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)
except AttributeError:
pass
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
@@ -393,7 +415,7 @@ class GifImageFile(ImageFile.ImageFile):
elif self.mode not in ("RGB", "RGBA"):
transparency = frame_transparency
self.tile = [
(
ImageFile._Tile(
"gif",
(x0, y0, x1, y1),
self.__offset,
@@ -409,7 +431,7 @@ class GifImageFile(ImageFile.ImageFile):
elif k in self.info:
del self.info[k]
def load_prepare(self):
def load_prepare(self) -> None:
temp_mode = "P" if self._frame_palette else "L"
self._prev_im = None
if self.__frame == 0:
@@ -421,15 +443,22 @@ class GifImageFile(ImageFile.ImageFile):
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(*self._frame_palette.getdata())
self.im.putpalette("RGB", *self._frame_palette.getdata())
else:
self.im = None
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):
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:
@@ -441,21 +470,37 @@ class GifImageFile(ImageFile.ImageFile):
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")
if self.mode == "L":
frame_im = self.im.convert_transparent("LA", self._frame_transparency)
else:
self.im.putpalettealpha(self._frame_transparency, 0)
frame_im = self.im.convert("RGBA")
else:
frame_im = self.im.convert("RGB")
assert self.dispose_extent is not None
frame_im = self._crop(frame_im, self.dispose_extent)
self.im = self._prev_im
self._mode = self.im.mode
if frame_im.mode == "RGBA":
if frame_im.mode in ("LA", "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
@@ -466,7 +511,7 @@ class GifImageFile(ImageFile.ImageFile):
RAWMODE = {"1": "L", "L": "L", "P": "P"}
def _normalize_mode(im):
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.
@@ -482,6 +527,7 @@ def _normalize_mode(im):
return im
if Image.getmodebase(im.mode) == "RGB":
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:
@@ -491,7 +537,12 @@ def _normalize_mode(im):
return im.convert("L")
def _normalize_palette(im, palette, info):
_Palette = 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.
@@ -513,14 +564,18 @@ 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])
index = im.palette.colors.get(source_color)
@@ -533,20 +588,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):
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):
@@ -559,26 +632,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 _getbbox(base_im, im_frame):
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("RGBA"), base_im.convert("RGBA")
)
return delta.getbbox(alpha_only=False)
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)
def _write_multiple_frames(im, fp, palette):
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], 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", [])):
@@ -589,12 +676,13 @@ def _write_multiple_frames(im, fp, palette):
for k, v in im_frame.info.items():
if k == "transparency":
continue
im.encoderinfo.setdefault(k, v)
if isinstance(k, str):
im.encoderinfo.setdefault(k, v)
encoderinfo = im.encoderinfo.copy()
im_frame = _normalize_palette(im_frame, palette, encoderinfo)
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:
@@ -603,63 +691,116 @@ def _write_multiple_frames(im, fp, palette):
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]
bbox = _getbbox(previous["im"], im_frame)
delta, bbox = _getbbox(previous_im, im_frame)
if not bbox:
# This frame is identical to the previous frame
if encoderinfo.get("duration"):
previous["encoderinfo"]["duration"] += encoderinfo["duration"]
im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"]
continue
if 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)
bbox = _getbbox(background_im, im_frame)
if im_frames[-1].encoderinfo.get("disposal") == 2:
# To appear correctly in viewers using a convention,
# only consider transparency, and not background color
color = im.encoderinfo.get(
"transparency", im.info.get("transparency")
)
if color is not None:
if background_im is None:
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]
else:
bbox = (0, 0) + im_frame.size
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})
previous_im = im_frame
im_frames.append(_Frame(diff_frame or im_frame, bbox, 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
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
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"])
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
if frame_data.bbox != (0, 0) + im_frame.size:
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)
@@ -670,7 +811,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
@@ -680,23 +821,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 = int(im.encoderinfo["transparency"])
except (KeyError, ValueError):
pass
else:
# 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 = im.encoderinfo["transparency"]
except KeyError:
transparency = None
if "duration" in im.encoderinfo:
duration = int(im.encoderinfo["duration"] / 10)
@@ -705,11 +836,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"!"
@@ -717,7 +846,7 @@ 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)
)
@@ -742,7 +871,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.
#
@@ -773,6 +902,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()
@@ -794,7 +924,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.
@@ -805,7 +935,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
@@ -827,6 +957,7 @@ def _get_optimize(im, info):
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
)
@@ -838,9 +969,10 @@ def _get_optimize(im, info):
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
@@ -850,7 +982,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
@@ -868,23 +1000,33 @@ 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 im.palette else b""
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, info_background):
def _get_background(
im: Image.Image,
info_background: int | tuple[int, int, int] | tuple[int, int, int, int] | None,
) -> int:
background = 0
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(info_background, im)
except ValueError as e:
@@ -901,7 +1043,7 @@ def _get_background(im, 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
@@ -963,7 +1105,12 @@ def _get_global_header(im, info):
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
@@ -971,7 +1118,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
@@ -983,7 +1132,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.
@@ -995,11 +1146,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"]
@@ -1011,7 +1162,9 @@ def getheader(im, palette=None, info=None):
return header, used_palette_colors
def getdata(im, offset=(0, 0), **params):
def getdata(
im: Image.Image, offset: tuple[int, int] = (0, 0), **params: Any
) -> list[bytes]:
"""
Legacy Method
@@ -1028,12 +1181,14 @@ def getdata(im, offset=(0, 0), **params):
: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

View File

@@ -18,17 +18,22 @@ Stuff to translate curve segments to palette values (derived from
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 ._binary import o8
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
EPSILON = 1e-10
"""""" # 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 +48,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,9 +69,22 @@ SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
class GradientFile:
gradient = None
gradient: (
list[
tuple[
float,
float,
float,
list[float],
list[float],
Callable[[float, float], float],
]
]
| None
) = None
def getpalette(self, entries=256):
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
assert self.gradient is not None
palette = []
ix = 0
@@ -101,8 +119,8 @@ class GradientFile:
class GimpGradientFile(GradientFile):
"""File handler for GIMP's gradient format."""
def __init__(self, fp):
if fp.readline()[:13] != b"GIMP Gradient":
def __init__(self, fp: IO[bytes]) -> None:
if not fp.readline().startswith(b"GIMP Gradient"):
msg = "not a GIMP gradient file"
raise SyntaxError(msg)
@@ -114,7 +132,7 @@ class GimpGradientFile(GradientFile):
count = int(line)
gradient = []
self.gradient = []
for i in range(count):
s = fp.readline().split()
@@ -132,6 +150,4 @@ class GimpGradientFile(GradientFile):
msg = "cannot handle HSV colour space"
raise OSError(msg)
gradient.append((x0, x1, xm, rgb0, rgb1, segment))
self.gradient = gradient
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))

View File

@@ -13,10 +13,14 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from io import BytesIO
from ._binary import o8
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO
class GimpPaletteFile:
@@ -24,14 +28,18 @@ class GimpPaletteFile:
rawmode = "RGB"
def __init__(self, fp):
self.palette = [o8(i) * 3 for i in range(256)]
if fp.readline()[:12] != b"GIMP Palette":
def _read(self, fp: IO[bytes], limit: bool = True) -> None:
if not fp.readline().startswith(b"GIMP Palette"):
msg = "not a GIMP palette file"
raise SyntaxError(msg)
for i in range(256):
palette: list[int] = []
i = 0
while True:
if limit and i == 256 + 3:
break
i += 1
s = fp.readline()
if not s:
break
@@ -39,18 +47,29 @@ class GimpPaletteFile:
# skip fields and comment lines
if re.match(rb"\w+:|#", s):
continue
if len(s) > 100:
if limit and len(s) > 100:
msg = "bad palette file"
raise SyntaxError(msg)
v = tuple(map(int, s.split()[:3]))
if len(v) != 3:
v = s.split(maxsplit=3)
if len(v) < 3:
msg = "bad palette entry"
raise ValueError(msg)
self.palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2])
palette += (int(v[i]) for i in range(3))
if limit and len(palette) == 768:
break
self.palette = b"".join(self.palette)
self.palette = bytes(palette)
def getpalette(self):
def __init__(self, fp: IO[bytes]) -> None:
self._read(fp)
@classmethod
def frombytes(cls, data: bytes) -> GimpPaletteFile:
self = cls.__new__(cls)
self._read(BytesIO(data), False)
return self
def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode

View File

@@ -8,13 +8,17 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
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,22 +32,20 @@ def register_handler(handler):
# Image adapter
def _accept(prefix):
return prefix[:4] == b"GRIB" and prefix[7] == 1
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
class GribStubImageFile(ImageFile.StubImageFile):
format = "GRIB"
format_description = "GRIB"
def _open(self):
offset = self.fp.tell()
def _open(self) -> None:
if not _accept(self.fp.read(8)):
msg = "Not a GRIB file"
raise SyntaxError(msg)
self.fp.seek(offset)
self.fp.seek(-8, os.SEEK_CUR)
# make something up
self._mode = "F"
@@ -53,11 +55,11 @@ class GribStubImageFile(ImageFile.StubImageFile):
if loader:
loader.open(self)
def _load(self):
def _load(self) -> ImageFile.StubHandler | None:
return _handler
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"):
msg = "GRIB save handler not installed"
raise OSError(msg)

View File

@@ -8,13 +8,17 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
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,22 +32,20 @@ def register_handler(handler):
# Image adapter
def _accept(prefix):
return prefix[:8] == b"\x89HDF\r\n\x1a\n"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\x89HDF\r\n\x1a\n")
class HDF5StubImageFile(ImageFile.StubImageFile):
format = "HDF5"
format_description = "HDF5"
def _open(self):
offset = self.fp.tell()
def _open(self) -> None:
if not _accept(self.fp.read(8)):
msg = "Not an HDF file"
raise SyntaxError(msg)
self.fp.seek(offset)
self.fp.seek(-8, os.SEEK_CUR)
# make something up
self._mode = "F"
@@ -53,11 +55,11 @@ class HDF5StubImageFile(ImageFile.StubImageFile):
if loader:
loader.open(self)
def _load(self):
def _load(self) -> ImageFile.StubHandler | None:
return _handler
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"):
msg = "HDF5 save handler not installed"
raise OSError(msg)

View File

@@ -16,11 +16,13 @@
#
# 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 . import Image, ImageFile, PngImagePlugin, features
@@ -32,11 +34,13 @@ 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)
@@ -47,7 +51,9 @@ def read_32t(fobj, start_length, size):
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,14 +76,14 @@ 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:
@@ -90,7 +96,9 @@ def read_32(fobj, start_length, size):
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)
@@ -100,18 +108,21 @@ 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)
if sig[:8] == b"\x89PNG\x0d\x0a\x1a\x0a":
im: Image.Image
if sig.startswith(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"
or sig[:4] == b"\x0d\x0a\x87\x0a"
sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
):
if not enable_jpeg2k:
@@ -162,12 +173,12 @@ 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 not _accept(sig):
@@ -181,11 +192,11 @@ class IcnsFile:
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:
@@ -194,14 +205,14 @@ class IcnsFile:
break
return sizes
def bestsize(self):
def bestsize(self) -> tuple[int, int, int]:
sizes = self.itersizes()
if not sizes:
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
@@ -214,18 +225,20 @@ class IcnsFile:
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:
@@ -251,7 +264,7 @@ 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.info["sizes"] = self.icns.itersizes()
@@ -262,39 +275,30 @@ class IcnsImageFile(ImageFile.ImageFile):
)
@property
def size(self):
def size(self) -> tuple[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"]:
msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg)
self._size = value
def size(self, value: tuple[int, int]) -> None:
# 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):
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],
)
def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
if 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:
if self._im is not None and self.im.size == self.size:
# Already loaded
return px
self.load_prepare()
@@ -311,7 +315,7 @@ class IcnsImageFile(ImageFile.ImageFile):
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.
@@ -345,36 +349,34 @@ def _save(im, fp, filename):
entries = []
for type, size in sizes.items():
stream = size_streams[size]
entries.append(
{"type": type, "size": HEADERSIZE + len(stream), "stream": stream}
)
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["size"] for entry in entries)
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", 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", 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):
return prefix[:4] == MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(MAGIC)
Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
@@ -391,8 +393,8 @@ if __name__ == "__main__":
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":

View File

@@ -20,11 +20,12 @@
# 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 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
@@ -39,7 +40,7 @@ from ._binary import o32le as o32
_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(
@@ -96,7 +97,9 @@ def _save(im, fp, filename):
if bits != 32:
and_mask = Image.new("1", size)
ImageFile._save(
and_mask, image_io, [("raw", (0, 0) + size, 0, ("1", 0, -1))]
and_mask,
image_io,
[ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
)
else:
frame.save(image_io, "png")
@@ -114,12 +117,26 @@ def _save(im, fp, filename):
fp.seek(current)
def _accept(prefix):
return prefix[:4] == _MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(_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
"""
@@ -140,73 +157,65 @@ class IcoFile:
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
width = s[0] or 256
height = s[1] or 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
# 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):
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"]):
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)
@@ -219,11 +228,10 @@ class IcoFile:
# 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
@@ -235,13 +243,19 @@ class IcoFile:
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]
@@ -253,25 +267,32 @@ class IcoFile:
# 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
@@ -304,33 +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"]:
msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg)
self._size = value
def load(self):
if self.im is not None 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 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.pyaccess = None
self._mode = im.mode
if im.palette:
self.palette = im.palette
if im.size != self.size:
warnings.warn("Image was not the expected size")
@@ -340,8 +362,9 @@ class IcoImageFile(ImageFile.ImageFile):
self.info["sizes"] = set(sizes)
self.size = im.size
return Image.Image.load(self)
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

View File

@@ -24,12 +24,14 @@
#
# 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 ._util import DeferredError
# --------------------------------------------------------------------
# Standard tags
@@ -78,7 +80,7 @@ OPEN = {
"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,8 +95,8 @@ for i in ["16", "16L", "16B"]:
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}")
# --------------------------------------------------------------------
@@ -103,7 +105,7 @@ for i in range(2, 33):
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
def number(s):
def number(s: Any) -> float:
try:
return int(s)
except ValueError:
@@ -119,7 +121,7 @@ class ImImageFile(ImageFile.ImageFile):
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.
@@ -144,7 +146,7 @@ class ImImageFile(ImageFile.ImageFile):
if s == b"\r":
continue
if not s or s == b"\0" or s == b"\x1A":
if not s or s == b"\0" or s == b"\x1a":
break
# FIXME: this may read whole file if not a text file
@@ -154,9 +156,9 @@ class ImImageFile(ImageFile.ImageFile):
msg = "not an IM file"
raise SyntaxError(msg)
if s[-2:] == b"\r\n":
if s.endswith(b"\r\n"):
s = s[:-2]
elif s[-1:] == b"\n":
elif s.endswith(b"\n"):
s = s[:-1]
try:
@@ -196,7 +198,7 @@ class ImImageFile(ImageFile.ImageFile):
n += 1
else:
msg = "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:
@@ -208,7 +210,7 @@ class ImImageFile(ImageFile.ImageFile):
self._mode = self.info[MODE]
# Skip forward to start of image data
while s and s[:1] != b"\x1A":
while s and not s.startswith(b"\x1a"):
s = self.fp.read(1)
if not s:
msg = "File truncated"
@@ -246,13 +248,17 @@ class ImImageFile(ImageFile.ImageFile):
self._fp = self.fp # FIXME: hack
if self.rawmode[:2] == "F;":
if self.rawmode.startswith("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
@@ -262,25 +268,31 @@ class ImImageFile(ImageFile.ImageFile):
# 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
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.frame = frame
@@ -294,9 +306,11 @@ class ImImageFile(ImageFile.ImageFile):
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
@@ -325,7 +339,7 @@ SAVE = {
}
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:
@@ -340,11 +354,13 @@ 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")
@@ -357,7 +373,9 @@ def _save(im, fp, filename):
palette += im_palette[colors * i : colors * (i + 1)]
palette += b"\x00" * (256 - colors)
fp.write(palette) # 768 bytes
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, 0, -1))]
)
#

File diff suppressed because it is too large Load Diff

View File

@@ -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,7 +38,7 @@ def duplicate(image):
return image.copy()
def invert(image):
def invert(image: Image.Image) -> Image.Image:
"""
Invert an image (channel). ::
@@ -49,7 +51,7 @@ 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. ::
@@ -64,7 +66,7 @@ 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. ::
@@ -79,7 +81,7 @@ 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. ::
@@ -94,7 +96,7 @@ 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.
@@ -111,7 +113,7 @@ 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. ::
@@ -125,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
@@ -137,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
@@ -149,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
@@ -161,7 +163,9 @@ 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. ::
@@ -176,7 +180,9 @@ 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. ::
@@ -191,7 +197,7 @@ 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):
def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Add two images, without clipping the result. ::
out = ((image1 + image2) % MAX)
@@ -204,7 +210,7 @@ def add_modulo(image1, image2):
return image1._new(image1.im.chop_add_modulo(image2.im))
def subtract_modulo(image1, image2):
def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
"""Subtract two images, without clipping the result. ::
out = ((image1 - image2) % MAX)
@@ -217,7 +223,7 @@ 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
@@ -235,7 +241,7 @@ 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". ::
@@ -250,7 +256,7 @@ 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". ::
@@ -265,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`.
@@ -275,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`.
@@ -285,7 +293,7 @@ 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``.

View File

@@ -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,22 +17,32 @@
# 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
from enum import IntEnum, IntFlag
from functools import reduce
from typing import Any, Literal, SupportsFloat, SupportsInt, Union
from . import Image
from ._deprecate import deprecate
from ._typing import SupportsRead
try:
from . 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 DeferredError
_imagingcms = DeferredError(ex)
core = DeferredError.new(ex)
DESCRIPTION = """
_DESCRIPTION = """
pyCMS
a Python / PIL interface to the littleCMS ICC Color Management System
@@ -92,11 +105,11 @@ pyCMS
"""
VERSION = "1.0.0 pil"
_VERSION = "1.0.0 pil"
# --------------------------------------------------------------------.
core = _imagingcms
#
# intent/direction values
@@ -118,7 +131,70 @@ class Direction(IntEnum):
#
# 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),
@@ -141,11 +217,6 @@ FLAGS = {
"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
@@ -156,13 +227,14 @@ for flag in FLAGS.values():
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
low-level profile object
"""
self.filename: str | None = None
if isinstance(profile, str):
if sys.platform == "win32":
@@ -171,24 +243,26 @@ class ImageCmsProfile:
profile_bytes_path.decode("ascii")
except UnicodeDecodeError:
with open(profile, "rb") as f:
self._set(core.profile_frombytes(f.read()))
self.profile = core.profile_frombytes(f.read())
return
self._set(core.profile_open(profile), profile)
self.filename = profile
self.profile = core.profile_open(profile)
elif hasattr(profile, "read"):
self._set(core.profile_frombytes(profile.read()))
elif isinstance(profile, _imagingcms.CmsProfile):
self._set(profile)
self.profile = core.profile_frombytes(profile.read())
elif isinstance(profile, core.CmsProfile):
self.profile = profile
else:
msg = "Invalid type for Profile"
msg = "Invalid type for Profile" # type: ignore[unreachable]
raise TypeError(msg)
def _set(self, profile, filename=None):
self.profile = profile
self.filename = filename
self.product_name = None # profile.product_name
self.product_info = None # profile.product_info
def __getattr__(self, name: str) -> Any:
if name in ("product_name", "product_info"):
deprecate(f"ImageCms.ImageCmsProfile.{name}", 13)
return None
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
raise AttributeError(msg)
def tobytes(self):
def tobytes(self) -> bytes:
"""
Returns the profile in a format suitable for embedding in
saved images.
@@ -200,7 +274,6 @@ class ImageCmsProfile:
class ImageCmsTransform(Image.ImagePointHandler):
"""
Transform. This can be used with the procedural API, or with the standard
:py:func:`~PIL.Image.Image.point` method.
@@ -210,14 +283,14 @@ 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,
):
if proof is None:
self.transform = core.buildTransform(
@@ -240,28 +313,26 @@ class ImageCmsTransform(Image.ImagePointHandler):
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:
msg = "mode mismatch"
raise ValueError(msg) # wrong output mode
self.transform.apply(im.im.id, im.im.id)
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.
@@ -271,12 +342,12 @@ def get_display_profile(handle=None):
if sys.platform != "win32":
return None
from . 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)
@@ -288,7 +359,6 @@ def get_display_profile(handle=None):
class PyCMSError(Exception):
"""(pyCMS) Exception class.
This is used for all errors in the pyCMS API."""
@@ -296,14 +366,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``.
@@ -391,7 +461,9 @@ def profileToProfile(
return imOut
def getOpenProfile(profileFilename):
def getOpenProfile(
profileFilename: str | SupportsRead[bytes] | core.CmsProfile,
) -> ImageCmsProfile:
"""
(pyCMS) Opens an ICC profile file.
@@ -414,13 +486,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
@@ -481,7 +553,7 @@ def buildTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
msg = "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:
@@ -497,15 +569,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
@@ -585,7 +657,7 @@ def buildProofTransform(
raise PyCMSError(msg)
if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG):
msg = "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:
@@ -613,16 +685,18 @@ buildTransformFromOpenProfiles = buildTransform
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.
@@ -636,13 +710,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
@@ -666,7 +740,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.
@@ -688,7 +764,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.
@@ -715,7 +791,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.
@@ -749,15 +825,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.
@@ -787,17 +863,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.
@@ -825,7 +898,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.
@@ -853,7 +926,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.
@@ -882,7 +955,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.
@@ -911,7 +984,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.
@@ -950,7 +1023,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.
@@ -999,11 +1074,3 @@ def isIntentSupported(profile, intent, direction):
return -1
except (AttributeError, OSError, TypeError, ValueError) as v:
raise PyCMSError(v) from v
def versions():
"""
(pyCMS) Fetches versions.
"""
return VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__

View File

@@ -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.
@@ -41,8 +44,10 @@ def getrgb(color):
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):
@@ -85,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(
@@ -102,15 +107,15 @@ 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)
@@ -120,11 +125,12 @@ def getrgb(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` 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 greyscale value.
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.
@@ -132,33 +138,34 @@ def getcolor(color, mode):
:param color: A color string
:param mode: Convert result to this mode
:return: ``(graylevel[, alpha]) or (red, green, blue[, alpha])``
: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[:3], color[3]
rgb, alpha = getrgb(color), 255
if len(rgb) == 4:
alpha = rgb[3]
rgb = rgb[:3]
if mode == "HSV":
from colorsys import rgb_to_hsv
r, g, b = color
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 = color
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
graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16
if mode[-1] == "A":
return color, alpha
else:
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.

File diff suppressed because it is too large Load Diff

View File

@@ -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 @@ class Pen:
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 @@ class Draw:
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,28 +195,31 @@ class Draw:
.. 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 textbbox(self, xy, 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.
@@ -179,11 +228,12 @@ class Draw:
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox`
"""
if self.transform:
xy = ImagePath.Path(xy)
xy.transform(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, font):
def textlength(self, text: AnyStr, font: Font) -> float:
"""
Returns length (in pixels) of given text.
This is the amount by which following text should be offset.

View File

@@ -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)

View File

@@ -26,16 +26,38 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import io
import itertools
import logging
import os
import struct
import sys
from typing import IO, Any, NamedTuple, cast
from . import Image
from ._util import is_path
from . import ExifTags, Image
from ._util import DeferredError, is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
from ._typing import StrOrBytesPath
logger = logging.getLogger(__name__)
MAXBLOCK = 65536
"""
By default, Pillow processes image data in blocks. This helps to prevent excessive use
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
When reading an image, this is the number of bytes to read at once.
When writing an image, this is the number of bytes to write at once.
If the image width times 4 is greater, then that will be used instead.
Plugins may also set a greater number.
User code may set this to another number.
"""
SAFEBLOCK = 1024 * 1024
@@ -61,22 +83,29 @@ Dict of known error codes returned from :meth:`.PyDecoder.decode`,
# Helpers
def raise_oserror(error):
def _get_oserror(error: int, *, encoder: bool) -> OSError:
try:
msg = Image.core.getcodecstatus(error)
except AttributeError:
msg = ERRORS.get(error)
if not msg:
msg = f"decoder error {error}"
msg += " when reading image file"
raise OSError(msg)
msg = f"{'encoder' if encoder else 'decoder'} error {error}"
msg += f" when {'writing' if encoder else 'reading'} image file"
return OSError(msg)
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
@@ -85,32 +114,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 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:
@@ -133,17 +164,105 @@ class ImageFile(Image.Image):
self.fp.close()
raise
def get_format_mimetype(self):
def _open(self) -> None:
pass
def _close_fp(self):
if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError):
if self._fp != self.fp:
self._fp.close()
self._fp = DeferredError(ValueError("Operation on closed image"))
if self.fp:
self.fp.close()
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 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:
self._close_fp()
self.fp = None
except Exception as msg:
logger.debug("Error closing: %s", msg)
super().close()
def get_child_images(self) -> list[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:
assert self.fp is not None
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)
length = ifd.get(ExifTags.Base.JpegIFByteCount)
assert isinstance(length, int)
data = self.fp.read(length)
fp = io.BytesIO(data)
with Image.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:
assert self.fp is not None
self.fp.seek(offset)
return child_images
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):
def __getstate__(self) -> list[Any]:
return super().__getstate__() + [self.filename]
def __setstate__(self, state: list[Any]) -> None:
self.tile = []
if len(state) > 5:
self.filename = state[5]
super().__setstate__(state)
def verify(self):
def verify(self) -> None:
"""Check file integrity"""
# raise exception if something's wrong. must be called
@@ -152,10 +271,10 @@ class ImageFile(Image.Image):
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:
if not self.tile and self._im is None:
msg = "cannot load this image"
raise OSError(msg)
@@ -163,36 +282,40 @@ class ImageFile(Image.Image):
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")
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
):
if offset < 0:
msg = "Tile offset cannot be negative"
raise ValueError(msg)
try:
# use mmap, if possible
import mmap
@@ -200,8 +323,8 @@ class ImageFile(Image.Image):
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():
# buffer is not large enough
raise OSError
msg = "buffer is not large enough"
raise OSError(msg)
self.im = Image.core.map_buffer(
self.map, self.size, decoder_name, offset, args
)
@@ -219,11 +342,8 @@ class ImageFile(Image.Image):
# 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 = [
@@ -232,7 +352,7 @@ class ImageFile(Image.Image):
self.tile, lambda tile: (tile[0], tile[1], tile[3])
)
]
for decoder_name, extents, offset, args in self.tile:
for i, (decoder_name, extents, offset, args) in enumerate(self.tile):
seek(offset)
decoder = Image._getdecoder(
self.mode, decoder_name, args, self.decoderconfig
@@ -245,8 +365,13 @@ class ImageFile(Image.Image):
else:
b = prefix
while True:
read_bytes = self.decodermaxblock
if i + 1 < len(self.tile):
next_offset = self.tile[i + 1].offset
if next_offset > offset:
read_bytes = next_offset - offset
try:
s = read(self.decodermaxblock)
s = read(read_bytes)
except (IndexError, struct.error) as e:
# truncated png/gif
if LOAD_TRUNCATED_IMAGES:
@@ -285,38 +410,38 @@ class ImageFile(Image.Image):
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
)
):
msg = "attempt to seek outside sequence"
@@ -325,7 +450,16 @@ class ImageFile(Image.Image):
return self.tell() != frame
class StubImageFile(ImageFile):
class StubHandler(abc.ABC):
def open(self, im: StubImageFile) -> None:
pass
@abc.abstractmethod
def load(self, im: StubImageFile) -> Image.Image:
pass
class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
"""
Base class for stub image loaders.
@@ -333,11 +467,11 @@ class StubImageFile(ImageFile):
certain format, but relies on external code to load the file.
"""
def _open(self):
msg = "StubImageFile subclass must implement _open"
raise NotImplementedError(msg)
@abc.abstractmethod
def _open(self) -> None:
pass
def load(self):
def load(self) -> Image.core.PixelAccess | None:
loader = self._load()
if loader is None:
msg = f"cannot find loader for this {self.format} file"
@@ -345,14 +479,14 @@ class StubImageFile(ImageFile):
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):
@abc.abstractmethod
def _load(self) -> StubHandler | None:
"""(Hook) Find actual image loader."""
msg = "StubImageFile subclass must implement _load"
raise NotImplementedError(msg)
pass
class Parser:
@@ -362,13 +496,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
@@ -376,7 +510,7 @@ class Parser:
"""
assert self.data is None, "cannot reuse parsers"
def feed(self, data):
def feed(self, data: bytes) -> None:
"""
(Consumer) Feed data to the parser.
@@ -412,7 +546,7 @@ class Parser:
if e < 0:
# decoding error
self.image = None
raise_oserror(e)
raise _get_oserror(e, encoder=False)
else:
# end of image
return
@@ -430,7 +564,6 @@ class Parser:
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")
@@ -453,13 +586,13 @@ class Parser:
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.
@@ -493,7 +626,7 @@ class Parser:
# --------------------------------------------------------------------
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.
@@ -521,13 +654,20 @@ def _save(im, fp, tile, bufsize=0):
fp.flush()
def _encode_tile(im, fp, tile, bufsize, fh, exc=None):
for e, b, o, a in tile:
if o > 0:
fp.seek(o)
encoder = Image._getencoder(im.mode, e, a, im.encoderconfig)
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, b)
encoder.setimage(im.im, extents)
if encoder.pushes_fd:
encoder.setfd(fp)
errcode = encoder.encode_to_pyfd()[1]
@@ -541,15 +681,15 @@ def _encode_tile(im, fp, tile, bufsize, fh, exc=None):
break
else:
# slight speedup: compress to real file object
assert fh is not None
errcode = encoder.encode_to_file(fh, bufsize)
if errcode < 0:
msg = f"encoder error {errcode} when writing image file"
raise OSError(msg) from exc
raise _get_oserror(errcode, encoder=True) from exc
finally:
encoder.cleanup()
def _safe_read(fp, size):
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
@@ -570,49 +710,51 @@ def _safe_read(fp, size):
msg = "Truncated File Read"
raise OSError(msg)
return data
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)
blocks.append(block)
remaining_size -= len(block)
if sum(len(d) for d in data) < size:
if sum(len(block) for block in blocks) < size:
msg = "Truncated File Read"
raise OSError(msg)
return b"".join(data)
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):
def extents(self) -> tuple[int, int, int, int]:
return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize
class PyCodec:
def __init__(self, mode, *args):
self.im = None
fd: IO[bytes] | 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 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
def cleanup(self):
def cleanup(self) -> None:
"""
Override to perform codec specific cleanup
@@ -620,7 +762,7 @@ class PyCodec:
"""
pass
def setfd(self, fd):
def setfd(self, fd: IO[bytes]) -> None:
"""
Called from ImageFile to set the Python file-like object
@@ -629,7 +771,11 @@ class PyCodec:
"""
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 codec
@@ -678,10 +824,10 @@ class PyDecoder(PyCodec):
_pulls_fd = False
@property
def pulls_fd(self):
def pulls_fd(self) -> bool:
return self._pulls_fd
def decode(self, buffer):
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
"""
Override to perform the decoding process.
@@ -690,21 +836,26 @@ class PyDecoder(PyCodec):
If finished with decoding return -1 for the bytes consumed.
Err codes are from :data:`.ImageFile.ERRORS`.
"""
raise NotImplementedError()
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)
@@ -727,10 +878,10 @@ class PyEncoder(PyCodec):
_pushes_fd = False
@property
def pushes_fd(self):
def pushes_fd(self) -> bool:
return self._pushes_fd
def encode(self, bufsize):
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
"""
Override to perform the encoding process.
@@ -739,9 +890,10 @@ class PyEncoder(PyCodec):
If finished with encoding return 1 for the error code.
Err codes are from :data:`.ImageFile.ERRORS`.
"""
raise NotImplementedError()
msg = "unavailable in base encoder"
raise NotImplementedError(msg)
def encode_to_pyfd(self):
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.
@@ -753,10 +905,11 @@ class PyEncoder(PyCodec):
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, bufsize):
def encode_to_file(self, fh: int, bufsize: int) -> int:
"""
:param fh: File handle.
:param bufsize: Buffer size.
@@ -769,5 +922,5 @@ class PyEncoder(PyCodec):
while errcode == 0:
status, errcode, buf = self.encode(bufsize)
if status > 0:
fh.write(buf[status:])
os.write(fh, buf[status:])
return errcode

View File

@@ -14,11 +14,27 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import functools
from collections.abc import Sequence
from typing import cast
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from typing import Any
from . import _imaging
from ._typing import NumpyArray
class Filter:
pass
class Filter(abc.ABC):
@abc.abstractmethod
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
pass
class MultibandFilter(Filter):
@@ -26,7 +42,9 @@ 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":
msg = "cannot filter palette images"
raise ValueError(msg)
@@ -35,26 +53,29 @@ class BuiltinFilter(MultibandFilter):
class Kernel(BuiltinFilter):
"""
Create a convolution kernel. The current version only
supports 3x3 and 5x5 integer and floating point kernels.
Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating
point kernels.
In the current version, kernels can only be applied to
"L" and "RGB" images.
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. 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.
: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)
@@ -77,11 +98,11 @@ 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":
msg = "cannot filter palette images"
raise ValueError(msg)
@@ -99,7 +120,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
@@ -114,7 +135,7 @@ class MinFilter(RankFilter):
name = "Min"
def __init__(self, size=3):
def __init__(self, size: int = 3) -> None:
self.size = size
self.rank = 0
@@ -129,7 +150,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
@@ -145,10 +166,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)
@@ -163,12 +184,12 @@ class GaussianBlur(MultibandFilter):
name = "GaussianBlur"
def __init__(self, radius=2):
def __init__(self, radius: float | Sequence[float] = 2) -> None:
self.radius = radius
def filter(self, image):
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius
if not isinstance(xy, (tuple, list)):
if isinstance(xy, (int, float)):
xy = (xy, xy)
if xy == (0, 0):
return image.copy()
@@ -191,18 +212,16 @@ class BoxBlur(MultibandFilter):
name = "BoxBlur"
def __init__(self, radius):
xy = radius
if not isinstance(xy, (tuple, list)):
xy = (xy, xy)
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):
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
xy = self.radius
if not isinstance(xy, (tuple, list)):
if isinstance(xy, (int, float)):
xy = (xy, xy)
if xy == (0, 0):
return image.copy()
@@ -222,16 +241,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)
@@ -376,7 +397,14 @@ 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):
msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg)
@@ -390,23 +418,24 @@ class Color3DLUT(MultibandFilter):
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
@@ -416,7 +445,8 @@ class Color3DLUT(MultibandFilter):
# 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:
msg = (
@@ -424,7 +454,8 @@ class Color3DLUT(MultibandFilter):
f"have a length of {channels}."
)
raise ValueError(msg)
table.extend(pixel)
flat_table.extend(pixel)
table = flat_table
if wrong_size or len(table) != items * channels:
msg = (
@@ -437,7 +468,7 @@ class Color3DLUT(MultibandFilter):
self.table = table
@staticmethod
def _check_size(size):
def _check_size(size: Any) -> tuple[int, int, int]:
try:
_, _, _ = size
except ValueError as e:
@@ -445,7 +476,7 @@ class Color3DLUT(MultibandFilter):
raise ValueError(msg) from e
except TypeError:
size = (size, size, size)
size = [int(x) for x in size]
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."
@@ -453,7 +484,13 @@ class Color3DLUT(MultibandFilter):
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.
@@ -470,7 +507,7 @@ class Color3DLUT(MultibandFilter):
msg = "Only 3 or 4 output channels are supported"
raise ValueError(msg)
table = [0] * (size_1d * size_2d * size_3d * channels)
table: list[float] = [0] * (size_1d * size_2d * size_3d * channels)
idx_out = 0
for b in range(size_3d):
for g in range(size_2d):
@@ -488,7 +525,13 @@ class Color3DLUT(MultibandFilter):
_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.
@@ -514,7 +557,7 @@ class Color3DLUT(MultibandFilter):
ch_out = channels or ch_in
size_1d, size_2d, size_3d = self.size
table = [0] * (size_1d * size_2d * size_3d * ch_out)
table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out)
idx_in = 0
idx_out = 0
for b in range(size_3d):
@@ -542,7 +585,7 @@ class Color3DLUT(MultibandFilter):
_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),
@@ -552,15 +595,13 @@ class Color3DLUT(MultibandFilter):
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.Resampling.BILINEAR,
self.channels,
self.size[0],
self.size[1],
self.size[2],
self.size,
self.table,
)

View File

@@ -25,15 +25,33 @@
# 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, Any, BinaryIO, TypedDict, cast
from . import Image
from ._util import is_directory, is_path
from ._typing import StrOrBytesPath
from ._util import DeferredError, is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import ImageFile
from ._imaging import ImagingFont
from ._imagingft import Font
class Axis(TypedDict):
minimum: int | None
default: int | None
maximum: int | None
name: bytes | None
class Layout(IntEnum):
@@ -44,15 +62,14 @@ class Layout(IntEnum):
MAX_STRING_LENGTH = 1_000_000
core: ModuleType | DeferredError
try:
from . import _imagingft as core
except ImportError as ex:
from ._util import DeferredError
core = DeferredError(ex)
core = DeferredError.new(ex)
def _string_length_check(text):
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)
@@ -77,14 +94,18 @@ def _string_length_check(text):
class ImageFont:
"""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
@@ -94,7 +115,8 @@ class ImageFont:
else:
if image:
image.close()
msg = "cannot find glyph data file"
msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}"
raise OSError(msg)
self.file = fullname
@@ -102,12 +124,17 @@ class ImageFont:
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:
# check image
if image.mode not in ("1", "L"):
msg = "invalid font image mode"
raise TypeError(msg)
# read PILfont header
if file.readline() != b"PILfont\n":
if file.read(8) != b"PILfont\n":
msg = "Not a PILfont file"
raise SyntaxError(msg)
file.readline().split(b";")
file.readline()
self.info = [] # FIXME: should be a dictionary
while True:
s = file.readline()
@@ -118,16 +145,13 @@ class ImageFont:
# read PILfont metrics
data = file.read(256 * 20)
# check image
if image.mode not in ("1", "L"):
msg = "invalid font image mode"
raise TypeError(msg)
image.load()
self.font = Image.core.font(image.im, data)
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,19 +169,19 @@ class ImageFont:
: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, *args, **kwargs):
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.
:param mode: Used by some graphics drivers to indicate what mode the
driver prefers; if empty, the renderer may return either
mode. Note that the mode is always a string, to simplify
C-level implementations.
:return: ``(left, top, right, bottom)`` bounding box
"""
@@ -165,7 +189,9 @@ class ImageFont:
width, height = self.font.getsize(text)
return 0, 0, width, height
def getlength(self, text, *args, **kwargs):
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.
@@ -185,9 +211,26 @@ class ImageFont:
class FreeTypeFont:
"""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
@@ -206,13 +249,14 @@ class FreeTypeFont:
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 is_path(font):
font = os.fspath(font)
if sys.platform == "win32":
font_bytes_path = font if isinstance(font, bytes) else font.encode()
try:
@@ -227,23 +271,23 @@ class FreeTypeFont:
font, size, index, encoding, layout_engine=layout_engine
)
else:
load_from_bytes(font)
load_from_bytes(cast(IO[bytes], font))
def __getstate__(self):
def __getstate__(self) -> list[Any]:
return [self.path, self.size, self.index, self.encoding, self.layout_engine]
def __setstate__(self, state):
def __setstate__(self, state: list[Any]) -> None:
path, size, index, encoding, layout_engine = state
self.__init__(path, size, index, encoding, layout_engine)
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
@@ -251,7 +295,14 @@ class FreeTypeFont:
"""
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.
@@ -325,14 +376,14 @@ class FreeTypeFont:
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.
@@ -375,8 +426,9 @@ class FreeTypeFont:
: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
"""
@@ -390,16 +442,16 @@ class FreeTypeFont:
def getmask(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=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,
ink: int = 0,
start: tuple[float, float] | None = None,
) -> Image.core.ImagingCore:
"""
Create a bitmap for the text.
@@ -449,8 +501,9 @@ class FreeTypeFont:
.. 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
@@ -480,18 +533,18 @@ class FreeTypeFont:
def getmask2(
self,
text,
mode="",
direction=None,
features=None,
language=None,
stroke_width=0,
anchor=None,
ink=0,
start=None,
*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.
@@ -541,8 +594,9 @@ class FreeTypeFont:
.. 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
@@ -562,22 +616,13 @@ class FreeTypeFont:
_string_length_check(text)
if start is None:
start = (0, 0)
im = None
size = None
def fill(mode, im_size):
nonlocal im, size
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)
size = im_size
if Image.MAX_IMAGE_PIXELS is not None:
pixels = max(1, size[0]) * max(1, size[1])
if pixels > 2 * Image.MAX_IMAGE_PIXELS:
return
im = Image.core.fill(mode, size)
return im
offset = self.font.render(
return self.font.render(
text,
fill,
mode,
@@ -585,17 +630,20 @@ class FreeTypeFont:
features,
language,
stroke_width,
kwargs.get("stroke_filled", False),
anchor,
ink,
start[0],
start[1],
start,
)
Image._decompression_bomb_check(size)
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.
@@ -618,19 +666,15 @@ class FreeTypeFont:
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.
"""
try:
names = self.font.getvarnames()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
names = self.font.getvarnames()
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.
@@ -649,36 +693,31 @@ class FreeTypeFont:
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.
"""
try:
axes = self.font.getvaraxes()
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
axes = self.font.getvaraxes()
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.
"""
try:
self.font.setvaraxes(axes)
except AttributeError as e:
msg = "FreeType 2.9.1 or greater is required"
raise NotImplementedError(msg) from e
self.font.setvaraxes(axes)
class TransposedFont:
"""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.
@@ -692,13 +731,17 @@ class TransposedFont:
self.font = font
self.orientation = orientation # any 'transpose' argument, or None
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 getbbox(self, text, *args, **kwargs):
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)
@@ -708,18 +751,18 @@ class TransposedFont:
return 0, 0, height, width
return 0, 0, width, height
def getlength(self, text, *args, **kwargs):
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)
_string_length_check(text)
return self.font.getlength(text, *args, **kwargs)
def load(filename):
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.
@@ -730,12 +773,19 @@ 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.
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
@@ -748,10 +798,15 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None):
: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 pixels.
:param index: Which font face to load (default is first available face).
@@ -775,7 +830,7 @@ 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.
@@ -788,9 +843,10 @@ 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:
@@ -809,12 +865,21 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None):
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",
@@ -840,7 +905,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None):
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.
@@ -849,21 +914,159 @@ 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 is_directory(directory):
if not isinstance(filename, str):
filename = filename.decode("utf-8")
try:
return load(os.path.join(directory, filename))
except OSError:
pass
msg = "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?'
raise OSError(msg)
def load_default(size=None):
def load_default_imagefont() -> ImageFont:
f = ImageFont()
f._load_pilfont_data(
# courB08
BytesIO(
base64.b64decode(
b"""
UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAA//8AAQAAAAAAAAABAAEA
BgAAAAH/+gADAAAAAQAAAAMABgAGAAAAAf/6AAT//QADAAAABgADAAYAAAAA//kABQABAAYAAAAL
AAgABgAAAAD/+AAFAAEACwAAABAACQAGAAAAAP/5AAUAAAAQAAAAFQAHAAYAAP////oABQAAABUA
AAAbAAYABgAAAAH/+QAE//wAGwAAAB4AAwAGAAAAAf/5AAQAAQAeAAAAIQAIAAYAAAAB//kABAAB
ACEAAAAkAAgABgAAAAD/+QAE//0AJAAAACgABAAGAAAAAP/6AAX//wAoAAAALQAFAAYAAAAB//8A
BAACAC0AAAAwAAMABgAAAAD//AAF//0AMAAAADUAAQAGAAAAAf//AAMAAAA1AAAANwABAAYAAAAB
//kABQABADcAAAA7AAgABgAAAAD/+QAFAAAAOwAAAEAABwAGAAAAAP/5AAYAAABAAAAARgAHAAYA
AAAA//kABQAAAEYAAABLAAcABgAAAAD/+QAFAAAASwAAAFAABwAGAAAAAP/5AAYAAABQAAAAVgAH
AAYAAAAA//kABQAAAFYAAABbAAcABgAAAAD/+QAFAAAAWwAAAGAABwAGAAAAAP/5AAUAAABgAAAA
ZQAHAAYAAAAA//kABQAAAGUAAABqAAcABgAAAAD/+QAFAAAAagAAAG8ABwAGAAAAAf/8AAMAAABv
AAAAcQAEAAYAAAAA//wAAwACAHEAAAB0AAYABgAAAAD/+gAE//8AdAAAAHgABQAGAAAAAP/7AAT/
/gB4AAAAfAADAAYAAAAB//oABf//AHwAAACAAAUABgAAAAD/+gAFAAAAgAAAAIUABgAGAAAAAP/5
AAYAAQCFAAAAiwAIAAYAAP////oABgAAAIsAAACSAAYABgAA////+gAFAAAAkgAAAJgABgAGAAAA
AP/6AAUAAACYAAAAnQAGAAYAAP////oABQAAAJ0AAACjAAYABgAA////+gAFAAAAowAAAKkABgAG
AAD////6AAUAAACpAAAArwAGAAYAAAAA//oABQAAAK8AAAC0AAYABgAA////+gAGAAAAtAAAALsA
BgAGAAAAAP/6AAQAAAC7AAAAvwAGAAYAAP////oABQAAAL8AAADFAAYABgAA////+gAGAAAAxQAA
AMwABgAGAAD////6AAUAAADMAAAA0gAGAAYAAP////oABQAAANIAAADYAAYABgAA////+gAGAAAA
2AAAAN8ABgAGAAAAAP/6AAUAAADfAAAA5AAGAAYAAP////oABQAAAOQAAADqAAYABgAAAAD/+gAF
AAEA6gAAAO8ABwAGAAD////6AAYAAADvAAAA9gAGAAYAAAAA//oABQAAAPYAAAD7AAYABgAA////
+gAFAAAA+wAAAQEABgAGAAD////6AAYAAAEBAAABCAAGAAYAAP////oABgAAAQgAAAEPAAYABgAA
////+gAGAAABDwAAARYABgAGAAAAAP/6AAYAAAEWAAABHAAGAAYAAP////oABgAAARwAAAEjAAYA
BgAAAAD/+gAFAAABIwAAASgABgAGAAAAAf/5AAQAAQEoAAABKwAIAAYAAAAA//kABAABASsAAAEv
AAgABgAAAAH/+QAEAAEBLwAAATIACAAGAAAAAP/5AAX//AEyAAABNwADAAYAAAAAAAEABgACATcA
AAE9AAEABgAAAAH/+QAE//wBPQAAAUAAAwAGAAAAAP/7AAYAAAFAAAABRgAFAAYAAP////kABQAA
AUYAAAFMAAcABgAAAAD/+wAFAAABTAAAAVEABQAGAAAAAP/5AAYAAAFRAAABVwAHAAYAAAAA//sA
BQAAAVcAAAFcAAUABgAAAAD/+QAFAAABXAAAAWEABwAGAAAAAP/7AAYAAgFhAAABZwAHAAYAAP//
//kABQAAAWcAAAFtAAcABgAAAAD/+QAGAAABbQAAAXMABwAGAAAAAP/5AAQAAgFzAAABdwAJAAYA
AP////kABgAAAXcAAAF+AAcABgAAAAD/+QAGAAABfgAAAYQABwAGAAD////7AAUAAAGEAAABigAF
AAYAAP////sABQAAAYoAAAGQAAUABgAAAAD/+wAFAAABkAAAAZUABQAGAAD////7AAUAAgGVAAAB
mwAHAAYAAAAA//sABgACAZsAAAGhAAcABgAAAAD/+wAGAAABoQAAAacABQAGAAAAAP/7AAYAAAGn
AAABrQAFAAYAAAAA//kABgAAAa0AAAGzAAcABgAA////+wAGAAABswAAAboABQAGAAD////7AAUA
AAG6AAABwAAFAAYAAP////sABgAAAcAAAAHHAAUABgAAAAD/+wAGAAABxwAAAc0ABQAGAAD////7
AAYAAgHNAAAB1AAHAAYAAAAA//sABQAAAdQAAAHZAAUABgAAAAH/+QAFAAEB2QAAAd0ACAAGAAAA
Av/6AAMAAQHdAAAB3gAHAAYAAAAA//kABAABAd4AAAHiAAgABgAAAAD/+wAF//0B4gAAAecAAgAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAB
//sAAwACAecAAAHpAAcABgAAAAD/+QAFAAEB6QAAAe4ACAAGAAAAAP/5AAYAAAHuAAAB9AAHAAYA
AAAA//oABf//AfQAAAH5AAUABgAAAAD/+QAGAAAB+QAAAf8ABwAGAAAAAv/5AAMAAgH/AAACAAAJ
AAYAAAAA//kABQABAgAAAAIFAAgABgAAAAH/+gAE//sCBQAAAggAAQAGAAAAAP/5AAYAAAIIAAAC
DgAHAAYAAAAB//kABf/+Ag4AAAISAAUABgAA////+wAGAAACEgAAAhkABQAGAAAAAP/7AAX//gIZ
AAACHgADAAYAAAAA//wABf/9Ah4AAAIjAAEABgAAAAD/+QAHAAACIwAAAioABwAGAAAAAP/6AAT/
+wIqAAACLgABAAYAAAAA//kABP/8Ai4AAAIyAAMABgAAAAD/+gAFAAACMgAAAjcABgAGAAAAAf/5
AAT//QI3AAACOgAEAAYAAAAB//kABP/9AjoAAAI9AAQABgAAAAL/+QAE//sCPQAAAj8AAgAGAAD/
///7AAYAAgI/AAACRgAHAAYAAAAA//kABgABAkYAAAJMAAgABgAAAAH//AAD//0CTAAAAk4AAQAG
AAAAAf//AAQAAgJOAAACUQADAAYAAAAB//kABP/9AlEAAAJUAAQABgAAAAH/+QAF//4CVAAAAlgA
BQAGAAD////7AAYAAAJYAAACXwAFAAYAAP////kABgAAAl8AAAJmAAcABgAA////+QAGAAACZgAA
Am0ABwAGAAD////5AAYAAAJtAAACdAAHAAYAAAAA//sABQACAnQAAAJ5AAcABgAA////9wAGAAAC
eQAAAoAACQAGAAD////3AAYAAAKAAAAChwAJAAYAAP////cABgAAAocAAAKOAAkABgAA////9wAG
AAACjgAAApUACQAGAAD////4AAYAAAKVAAACnAAIAAYAAP////cABgAAApwAAAKjAAkABgAA////
+gAGAAACowAAAqoABgAGAAAAAP/6AAUAAgKqAAACrwAIAAYAAP////cABQAAAq8AAAK1AAkABgAA
////9wAFAAACtQAAArsACQAGAAD////3AAUAAAK7AAACwQAJAAYAAP////gABQAAAsEAAALHAAgA
BgAAAAD/9wAEAAACxwAAAssACQAGAAAAAP/3AAQAAALLAAACzwAJAAYAAAAA//cABAAAAs8AAALT
AAkABgAAAAD/+AAEAAAC0wAAAtcACAAGAAD////6AAUAAALXAAAC3QAGAAYAAP////cABgAAAt0A
AALkAAkABgAAAAD/9wAFAAAC5AAAAukACQAGAAAAAP/3AAUAAALpAAAC7gAJAAYAAAAA//cABQAA
Au4AAALzAAkABgAAAAD/9wAFAAAC8wAAAvgACQAGAAAAAP/4AAUAAAL4AAAC/QAIAAYAAAAA//oA
Bf//Av0AAAMCAAUABgAA////+gAGAAADAgAAAwkABgAGAAD////3AAYAAAMJAAADEAAJAAYAAP//
//cABgAAAxAAAAMXAAkABgAA////9wAGAAADFwAAAx4ACQAGAAD////4AAYAAAAAAAoABwASAAYA
AP////cABgAAAAcACgAOABMABgAA////+gAFAAAADgAKABQAEAAGAAD////6AAYAAAAUAAoAGwAQ
AAYAAAAA//gABgAAABsACgAhABIABgAAAAD/+AAGAAAAIQAKACcAEgAGAAAAAP/4AAYAAAAnAAoA
LQASAAYAAAAA//gABgAAAC0ACgAzABIABgAAAAD/+QAGAAAAMwAKADkAEQAGAAAAAP/3AAYAAAA5
AAoAPwATAAYAAP////sABQAAAD8ACgBFAA8ABgAAAAD/+wAFAAIARQAKAEoAEQAGAAAAAP/4AAUA
AABKAAoATwASAAYAAAAA//gABQAAAE8ACgBUABIABgAAAAD/+AAFAAAAVAAKAFkAEgAGAAAAAP/5
AAUAAABZAAoAXgARAAYAAAAA//gABgAAAF4ACgBkABIABgAAAAD/+AAGAAAAZAAKAGoAEgAGAAAA
AP/4AAYAAABqAAoAcAASAAYAAAAA//kABgAAAHAACgB2ABEABgAAAAD/+AAFAAAAdgAKAHsAEgAG
AAD////4AAYAAAB7AAoAggASAAYAAAAA//gABQAAAIIACgCHABIABgAAAAD/+AAFAAAAhwAKAIwA
EgAGAAAAAP/4AAUAAACMAAoAkQASAAYAAAAA//gABQAAAJEACgCWABIABgAAAAD/+QAFAAAAlgAK
AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA
pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG
AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA////
+QAGAAIAzgAKANUAEw==
"""
)
),
Image.open(
BytesIO(
base64.b64decode(
b"""
iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u
Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9
M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g
LeNZUworuN1cjTPIzrTX6ofHWeo3v336qPzfEwRmBnHTtf95/fglZK5N0PDgfRTslpGBvz7LFc4F
IUXBWQGjQ5MGCx34EDFPwXiY4YbYxavpnhHFrk14CDAAAAD//wBlAJr/AgKqRooH2gAgPeggvUAA
Bu2WfgPoAwzRAABAAAAAAACQgLz/3Uv4Gv+gX7BJgDeeGP6AAAD1NMDzKHD7ANWr3loYbxsAD791
NAADfcoIDyP44K/jv4Y63/Z+t98Ovt+ub4T48LAAAAD//wBlAJr/AuplMlADJAAAAGuAphWpqhMx
in0A/fRvAYBABPgBwBUgABBQ/sYAyv9g0bCHgOLoGAAAAAAAREAAwI7nr0ArYpow7aX8//9LaP/9
SjdavWA8ePHeBIKB//81/83ndznOaXx379wAAAD//wBlAJr/AqDxW+D3AABAAbUh/QMnbQag/gAY
AYDAAACgtgD/gOqAAAB5IA/8AAAk+n9w0AAA8AAAmFRJuPo27ciC0cD5oeW4E7KA/wD3ECMAn2tt
y8PgwH8AfAxFzC0JzeAMtratAsC/ffwAAAD//wBlAJr/BGKAyCAA4AAAAvgeYTAwHd1kmQF5chkG
ABoMIHcL5xVpTfQbUqzlAAAErwAQBgAAEOClA5D9il08AEh/tUzdCBsXkbgACED+woQg8Si9VeqY
lODCn7lmF6NhnAEYgAAA/NMIAAAAAAD//2JgjLZgVGBg5Pv/Tvpc8hwGBjYGJADjHDrAwPzAjv/H
/Wf3PzCwtzcwHmBgYGcwbZz8wHaCAQMDOwMDQ8MCBgYOC3W7mp+f0w+wHOYxO3OG+e376hsMZjk3
AAAAAP//YmCMY2A4wMAIN5e5gQETPD6AZisDAwMDgzSDAAPjByiHcQMDAwMDg1nOze1lByRu5/47
c4859311AYNZzg0AAAAA//9iYGDBYihOIIMuwIjGL39/fwffA8b//xv/P2BPtzzHwCBjUQAAAAD/
/yLFBrIBAAAA//9i1HhcwdhizX7u8NZNzyLbvT97bfrMf/QHI8evOwcSqGUJAAAA//9iYBB81iSw
pEE170Qrg5MIYydHqwdDQRMrAwcVrQAAAAD//2J4x7j9AAMDn8Q/BgYLBoaiAwwMjPdvMDBYM1Tv
oJodAAAAAP//Yqo/83+dxePWlxl3npsel9lvLfPcqlE9725C+acfVLMEAAAA//9i+s9gwCoaaGMR
evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA
AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v//
Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR
w7IkEbzhVQAAAABJRU5ErkJggg==
"""
)
)
),
)
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.
https://dotcolon.net/fonts/aileron, with a more limited character set.
Otherwise, load a "better than nothing" font.
@@ -875,8 +1078,8 @@ def load_default(size=None):
:return: A font object.
"""
if core.__class__.__name__ == "module" or size is not None:
f = truetype(
if isinstance(core, ModuleType) or size is not None:
return truetype(
BytesIO(
base64.b64decode(
b"""
@@ -1106,137 +1309,4 @@ AAAAAAQAAAADa3tfFAAAAANAan9kAAAAA4QodoQ==
10 if size is None else size,
layout_engine=Layout.BASIC,
)
else:
f = ImageFont()
f._load_pilfont_data(
# courB08
BytesIO(
base64.b64decode(
b"""
UElMZm9udAo7Ozs7OzsxMDsKREFUQQoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAA//8AAQAAAAAAAAABAAEA
BgAAAAH/+gADAAAAAQAAAAMABgAGAAAAAf/6AAT//QADAAAABgADAAYAAAAA//kABQABAAYAAAAL
AAgABgAAAAD/+AAFAAEACwAAABAACQAGAAAAAP/5AAUAAAAQAAAAFQAHAAYAAP////oABQAAABUA
AAAbAAYABgAAAAH/+QAE//wAGwAAAB4AAwAGAAAAAf/5AAQAAQAeAAAAIQAIAAYAAAAB//kABAAB
ACEAAAAkAAgABgAAAAD/+QAE//0AJAAAACgABAAGAAAAAP/6AAX//wAoAAAALQAFAAYAAAAB//8A
BAACAC0AAAAwAAMABgAAAAD//AAF//0AMAAAADUAAQAGAAAAAf//AAMAAAA1AAAANwABAAYAAAAB
//kABQABADcAAAA7AAgABgAAAAD/+QAFAAAAOwAAAEAABwAGAAAAAP/5AAYAAABAAAAARgAHAAYA
AAAA//kABQAAAEYAAABLAAcABgAAAAD/+QAFAAAASwAAAFAABwAGAAAAAP/5AAYAAABQAAAAVgAH
AAYAAAAA//kABQAAAFYAAABbAAcABgAAAAD/+QAFAAAAWwAAAGAABwAGAAAAAP/5AAUAAABgAAAA
ZQAHAAYAAAAA//kABQAAAGUAAABqAAcABgAAAAD/+QAFAAAAagAAAG8ABwAGAAAAAf/8AAMAAABv
AAAAcQAEAAYAAAAA//wAAwACAHEAAAB0AAYABgAAAAD/+gAE//8AdAAAAHgABQAGAAAAAP/7AAT/
/gB4AAAAfAADAAYAAAAB//oABf//AHwAAACAAAUABgAAAAD/+gAFAAAAgAAAAIUABgAGAAAAAP/5
AAYAAQCFAAAAiwAIAAYAAP////oABgAAAIsAAACSAAYABgAA////+gAFAAAAkgAAAJgABgAGAAAA
AP/6AAUAAACYAAAAnQAGAAYAAP////oABQAAAJ0AAACjAAYABgAA////+gAFAAAAowAAAKkABgAG
AAD////6AAUAAACpAAAArwAGAAYAAAAA//oABQAAAK8AAAC0AAYABgAA////+gAGAAAAtAAAALsA
BgAGAAAAAP/6AAQAAAC7AAAAvwAGAAYAAP////oABQAAAL8AAADFAAYABgAA////+gAGAAAAxQAA
AMwABgAGAAD////6AAUAAADMAAAA0gAGAAYAAP////oABQAAANIAAADYAAYABgAA////+gAGAAAA
2AAAAN8ABgAGAAAAAP/6AAUAAADfAAAA5AAGAAYAAP////oABQAAAOQAAADqAAYABgAAAAD/+gAF
AAEA6gAAAO8ABwAGAAD////6AAYAAADvAAAA9gAGAAYAAAAA//oABQAAAPYAAAD7AAYABgAA////
+gAFAAAA+wAAAQEABgAGAAD////6AAYAAAEBAAABCAAGAAYAAP////oABgAAAQgAAAEPAAYABgAA
////+gAGAAABDwAAARYABgAGAAAAAP/6AAYAAAEWAAABHAAGAAYAAP////oABgAAARwAAAEjAAYA
BgAAAAD/+gAFAAABIwAAASgABgAGAAAAAf/5AAQAAQEoAAABKwAIAAYAAAAA//kABAABASsAAAEv
AAgABgAAAAH/+QAEAAEBLwAAATIACAAGAAAAAP/5AAX//AEyAAABNwADAAYAAAAAAAEABgACATcA
AAE9AAEABgAAAAH/+QAE//wBPQAAAUAAAwAGAAAAAP/7AAYAAAFAAAABRgAFAAYAAP////kABQAA
AUYAAAFMAAcABgAAAAD/+wAFAAABTAAAAVEABQAGAAAAAP/5AAYAAAFRAAABVwAHAAYAAAAA//sA
BQAAAVcAAAFcAAUABgAAAAD/+QAFAAABXAAAAWEABwAGAAAAAP/7AAYAAgFhAAABZwAHAAYAAP//
//kABQAAAWcAAAFtAAcABgAAAAD/+QAGAAABbQAAAXMABwAGAAAAAP/5AAQAAgFzAAABdwAJAAYA
AP////kABgAAAXcAAAF+AAcABgAAAAD/+QAGAAABfgAAAYQABwAGAAD////7AAUAAAGEAAABigAF
AAYAAP////sABQAAAYoAAAGQAAUABgAAAAD/+wAFAAABkAAAAZUABQAGAAD////7AAUAAgGVAAAB
mwAHAAYAAAAA//sABgACAZsAAAGhAAcABgAAAAD/+wAGAAABoQAAAacABQAGAAAAAP/7AAYAAAGn
AAABrQAFAAYAAAAA//kABgAAAa0AAAGzAAcABgAA////+wAGAAABswAAAboABQAGAAD////7AAUA
AAG6AAABwAAFAAYAAP////sABgAAAcAAAAHHAAUABgAAAAD/+wAGAAABxwAAAc0ABQAGAAD////7
AAYAAgHNAAAB1AAHAAYAAAAA//sABQAAAdQAAAHZAAUABgAAAAH/+QAFAAEB2QAAAd0ACAAGAAAA
Av/6AAMAAQHdAAAB3gAHAAYAAAAA//kABAABAd4AAAHiAAgABgAAAAD/+wAF//0B4gAAAecAAgAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAB
//sAAwACAecAAAHpAAcABgAAAAD/+QAFAAEB6QAAAe4ACAAGAAAAAP/5AAYAAAHuAAAB9AAHAAYA
AAAA//oABf//AfQAAAH5AAUABgAAAAD/+QAGAAAB+QAAAf8ABwAGAAAAAv/5AAMAAgH/AAACAAAJ
AAYAAAAA//kABQABAgAAAAIFAAgABgAAAAH/+gAE//sCBQAAAggAAQAGAAAAAP/5AAYAAAIIAAAC
DgAHAAYAAAAB//kABf/+Ag4AAAISAAUABgAA////+wAGAAACEgAAAhkABQAGAAAAAP/7AAX//gIZ
AAACHgADAAYAAAAA//wABf/9Ah4AAAIjAAEABgAAAAD/+QAHAAACIwAAAioABwAGAAAAAP/6AAT/
+wIqAAACLgABAAYAAAAA//kABP/8Ai4AAAIyAAMABgAAAAD/+gAFAAACMgAAAjcABgAGAAAAAf/5
AAT//QI3AAACOgAEAAYAAAAB//kABP/9AjoAAAI9AAQABgAAAAL/+QAE//sCPQAAAj8AAgAGAAD/
///7AAYAAgI/AAACRgAHAAYAAAAA//kABgABAkYAAAJMAAgABgAAAAH//AAD//0CTAAAAk4AAQAG
AAAAAf//AAQAAgJOAAACUQADAAYAAAAB//kABP/9AlEAAAJUAAQABgAAAAH/+QAF//4CVAAAAlgA
BQAGAAD////7AAYAAAJYAAACXwAFAAYAAP////kABgAAAl8AAAJmAAcABgAA////+QAGAAACZgAA
Am0ABwAGAAD////5AAYAAAJtAAACdAAHAAYAAAAA//sABQACAnQAAAJ5AAcABgAA////9wAGAAAC
eQAAAoAACQAGAAD////3AAYAAAKAAAAChwAJAAYAAP////cABgAAAocAAAKOAAkABgAA////9wAG
AAACjgAAApUACQAGAAD////4AAYAAAKVAAACnAAIAAYAAP////cABgAAApwAAAKjAAkABgAA////
+gAGAAACowAAAqoABgAGAAAAAP/6AAUAAgKqAAACrwAIAAYAAP////cABQAAAq8AAAK1AAkABgAA
////9wAFAAACtQAAArsACQAGAAD////3AAUAAAK7AAACwQAJAAYAAP////gABQAAAsEAAALHAAgA
BgAAAAD/9wAEAAACxwAAAssACQAGAAAAAP/3AAQAAALLAAACzwAJAAYAAAAA//cABAAAAs8AAALT
AAkABgAAAAD/+AAEAAAC0wAAAtcACAAGAAD////6AAUAAALXAAAC3QAGAAYAAP////cABgAAAt0A
AALkAAkABgAAAAD/9wAFAAAC5AAAAukACQAGAAAAAP/3AAUAAALpAAAC7gAJAAYAAAAA//cABQAA
Au4AAALzAAkABgAAAAD/9wAFAAAC8wAAAvgACQAGAAAAAP/4AAUAAAL4AAAC/QAIAAYAAAAA//oA
Bf//Av0AAAMCAAUABgAA////+gAGAAADAgAAAwkABgAGAAD////3AAYAAAMJAAADEAAJAAYAAP//
//cABgAAAxAAAAMXAAkABgAA////9wAGAAADFwAAAx4ACQAGAAD////4AAYAAAAAAAoABwASAAYA
AP////cABgAAAAcACgAOABMABgAA////+gAFAAAADgAKABQAEAAGAAD////6AAYAAAAUAAoAGwAQ
AAYAAAAA//gABgAAABsACgAhABIABgAAAAD/+AAGAAAAIQAKACcAEgAGAAAAAP/4AAYAAAAnAAoA
LQASAAYAAAAA//gABgAAAC0ACgAzABIABgAAAAD/+QAGAAAAMwAKADkAEQAGAAAAAP/3AAYAAAA5
AAoAPwATAAYAAP////sABQAAAD8ACgBFAA8ABgAAAAD/+wAFAAIARQAKAEoAEQAGAAAAAP/4AAUA
AABKAAoATwASAAYAAAAA//gABQAAAE8ACgBUABIABgAAAAD/+AAFAAAAVAAKAFkAEgAGAAAAAP/5
AAUAAABZAAoAXgARAAYAAAAA//gABgAAAF4ACgBkABIABgAAAAD/+AAGAAAAZAAKAGoAEgAGAAAA
AP/4AAYAAABqAAoAcAASAAYAAAAA//kABgAAAHAACgB2ABEABgAAAAD/+AAFAAAAdgAKAHsAEgAG
AAD////4AAYAAAB7AAoAggASAAYAAAAA//gABQAAAIIACgCHABIABgAAAAD/+AAFAAAAhwAKAIwA
EgAGAAAAAP/4AAUAAACMAAoAkQASAAYAAAAA//gABQAAAJEACgCWABIABgAAAAD/+QAFAAAAlgAK
AJsAEQAGAAAAAP/6AAX//wCbAAoAoAAPAAYAAAAA//oABQABAKAACgClABEABgAA////+AAGAAAA
pQAKAKwAEgAGAAD////4AAYAAACsAAoAswASAAYAAP////gABgAAALMACgC6ABIABgAA////+QAG
AAAAugAKAMEAEQAGAAD////4AAYAAgDBAAoAyAAUAAYAAP////kABQACAMgACgDOABMABgAA////
+QAGAAIAzgAKANUAEw==
"""
)
),
Image.open(
BytesIO(
base64.b64decode(
b"""
iVBORw0KGgoAAAANSUhEUgAAAx4AAAAUAQAAAAArMtZoAAAEwElEQVR4nABlAJr/AHVE4czCI/4u
Mc4b7vuds/xzjz5/3/7u/n9vMe7vnfH/9++vPn/xyf5zhxzjt8GHw8+2d83u8x27199/nxuQ6Od9
M43/5z2I+9n9ZtmDBwMQECDRQw/eQIQohJXxpBCNVE6QCCAAAAD//wBlAJr/AgALyj1t/wINwq0g
LeNZUworuN1cjTPIzrTX6ofHWeo3v336qPzfEwRmBnHTtf95/fglZK5N0PDgfRTslpGBvz7LFc4F
IUXBWQGjQ5MGCx34EDFPwXiY4YbYxavpnhHFrk14CDAAAAD//wBlAJr/AgKqRooH2gAgPeggvUAA
Bu2WfgPoAwzRAABAAAAAAACQgLz/3Uv4Gv+gX7BJgDeeGP6AAAD1NMDzKHD7ANWr3loYbxsAD791
NAADfcoIDyP44K/jv4Y63/Z+t98Ovt+ub4T48LAAAAD//wBlAJr/AuplMlADJAAAAGuAphWpqhMx
in0A/fRvAYBABPgBwBUgABBQ/sYAyv9g0bCHgOLoGAAAAAAAREAAwI7nr0ArYpow7aX8//9LaP/9
SjdavWA8ePHeBIKB//81/83ndznOaXx379wAAAD//wBlAJr/AqDxW+D3AABAAbUh/QMnbQag/gAY
AYDAAACgtgD/gOqAAAB5IA/8AAAk+n9w0AAA8AAAmFRJuPo27ciC0cD5oeW4E7KA/wD3ECMAn2tt
y8PgwH8AfAxFzC0JzeAMtratAsC/ffwAAAD//wBlAJr/BGKAyCAA4AAAAvgeYTAwHd1kmQF5chkG
ABoMIHcL5xVpTfQbUqzlAAAErwAQBgAAEOClA5D9il08AEh/tUzdCBsXkbgACED+woQg8Si9VeqY
lODCn7lmF6NhnAEYgAAA/NMIAAAAAAD//2JgjLZgVGBg5Pv/Tvpc8hwGBjYGJADjHDrAwPzAjv/H
/Wf3PzCwtzcwHmBgYGcwbZz8wHaCAQMDOwMDQ8MCBgYOC3W7mp+f0w+wHOYxO3OG+e376hsMZjk3
AAAAAP//YmCMY2A4wMAIN5e5gQETPD6AZisDAwMDgzSDAAPjByiHcQMDAwMDg1nOze1lByRu5/47
c4859311AYNZzg0AAAAA//9iYGDBYihOIIMuwIjGL39/fwffA8b//xv/P2BPtzzHwCBjUQAAAAD/
/yLFBrIBAAAA//9i1HhcwdhizX7u8NZNzyLbvT97bfrMf/QHI8evOwcSqGUJAAAA//9iYBB81iSw
pEE170Qrg5MIYydHqwdDQRMrAwcVrQAAAAD//2J4x7j9AAMDn8Q/BgYLBoaiAwwMjPdvMDBYM1Tv
oJodAAAAAP//Yqo/83+dxePWlxl3npsel9lvLfPcqlE9725C+acfVLMEAAAA//9i+s9gwCoaaGMR
evta/58PTEWzr21hufPjA8N+qlnBwAAAAAD//2JiWLci5v1+HmFXDqcnULE/MxgYGBj+f6CaJQAA
AAD//2Ji2FrkY3iYpYC5qDeGgeEMAwPDvwQBBoYvcTwOVLMEAAAA//9isDBgkP///0EOg9z35v//
Gc/eeW7BwPj5+QGZhANUswMAAAD//2JgqGBgYGBgqEMXlvhMPUsAAAAA//8iYDd1AAAAAP//AwDR
w7IkEbzhVQAAAABJRU5ErkJggg==
"""
)
)
),
)
return f
return load_default_imagefont()

View File

@@ -14,6 +14,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
@@ -24,8 +25,19 @@ import tempfile
from . import Image
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import ImageWin
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,
window: int | ImageWin.HWND | None = None,
) -> Image.Image:
im: Image.Image
if xdisplay is None:
if sys.platform == "darwin":
fh, filepath = tempfile.mkstemp(".png")
@@ -44,8 +56,12 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N
return im_resized
return im
elif sys.platform == "win32":
if window is not None:
all_screens = -1
offset, size, data = Image.core.grabscreen_win32(
include_layered_windows, all_screens
include_layered_windows,
all_screens,
int(window) if window is not None else 0,
)
im = Image.frombytes(
"RGB",
@@ -62,20 +78,26 @@ 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
# 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(xdisplay)
size, data = Image.core.grabscreen_x11(display_name)
except OSError:
if (
xdisplay is None
and sys.platform not in ("darwin", "win32")
and shutil.which("gnome-screenshot")
):
if display_name is None and sys.platform not in ("darwin", "win32"):
if shutil.which("gnome-screenshot"):
args = ["gnome-screenshot", "-f"]
elif shutil.which("grim"):
args = ["grim"]
elif shutil.which("spectacle"):
args = ["spectacle", "-n", "-b", "-f", "-o"]
else:
raise
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
subprocess.call(["gnome-screenshot", "-f", filepath])
subprocess.call(args + [filepath])
im = Image.open(filepath)
im.load()
os.unlink(filepath)
@@ -93,40 +115,29 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N
return im
def grabclipboard():
def grabclipboard() -> Image.Image | list[str] | None:
if sys.platform == "darwin":
fh, filepath = tempfile.mkstemp(".png")
os.close(fh)
commands = [
'set theFile to (open for access POSIX file "'
+ filepath
+ '" with write permission)',
"try",
" write (the clipboard as «class PNGf») 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
import struct
o = struct.unpack_from("I", data)[0]
if data[16] != 0:
files = data[o:].decode("utf-16le").split("\0")
else:
if data[16] == 0:
files = data[o:].decode("mbcs").split("\0")
else:
files = data[o:].decode("utf-16le").split("\0")
return files[: files.index("")]
if isinstance(data, bytes):
data = io.BytesIO(data)
@@ -148,18 +159,7 @@ def grabclipboard():
session_type = None
if shutil.which("wl-paste") and session_type in ("wayland", None):
output = subprocess.check_output(["wl-paste", "-l"]).decode()
mimetypes = output.splitlines()
if "image/png" in mimetypes:
mimetype = "image/png"
elif mimetypes:
mimetype = mimetypes[0]
else:
mimetype = None
args = ["wl-paste"]
if mimetype:
args.extend(["-t", mimetype])
args = ["wl-paste", "-t", "image"]
elif shutil.which("xclip") and session_type in ("x11", None):
args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]
else:
@@ -167,10 +167,29 @@ def grabclipboard():
raise NotImplementedError(msg)
p = subprocess.run(args, capture_output=True)
err = p.stderr
if err:
msg = f"{args[0]} error: {err.strip().decode()}"
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()

View File

@@ -14,23 +14,26 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import builtins
from . import Image, _imagingmath
def _isconstant(v):
return isinstance(v, (int, float))
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from types import CodeType
from typing import Any
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.
@@ -43,209 +46,257 @@ class _Operand:
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:
msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e
_imagingmath.unop(op, out.im.id, im1.im.id)
_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.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, 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:
msg = f"bad operand type for '{op}'"
raise TypeError(msg) from e
_imagingmath.binop(op, out.im.id, im1.im.id, im2.im.id)
_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], **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 **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.
"""
args: dict[str, Any] = ops.copy()
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, **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 **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.
"""
# build execution namespace
args = ops.copy()
args.update(_dict)
args: dict[str, Any] = ops.copy()
for k in kw:
if "__" in k or hasattr(builtins, k):
msg = f"'{k}' not allowed"
raise ValueError(msg)
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)
compiled_code = compile(expression, "<string>", "eval")
def scan(code):
def scan(code: CodeType) -> None:
for const in code.co_consts:
if type(const) is type(compiled_code):
scan(const)

View File

@@ -12,79 +12,74 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
# mode descriptor cache
_modes = None
from functools import lru_cache
from typing import NamedTuple
class ModeDescriptor:
class ModeDescriptor(NamedTuple):
"""Wrapper for mode strings."""
def __init__(self, mode, bands, basemode, basetype, typestr):
self.mode = mode
self.bands = bands
self.basemode = basemode
self.basetype = basetype
self.typestr = typestr
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 = {}
endian = "<" if sys.byteorder == "little" else ">"
for m, (basemode, basetype, bands, typestr) in {
# core modes
# Bits need to be extended to bytes
"1": ("L", "L", ("1",), "|b1"),
"L": ("L", "L", ("L",), "|u1"),
"I": ("L", "I", ("I",), endian + "i4"),
"F": ("L", "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"),
}.items():
modes[m] = ModeDescriptor(m, bands, basemode, basetype, typestr)
# mapping modes
for i16mode, typestr in {
# I;16 == I;16L, and I;32 == I;32L
"I;16": "<u2",
"I;16S": "<i2",
"I;16L": "<u2",
"I;16LS": "<i2",
"I;16B": ">u2",
"I;16BS": ">i2",
"I;16N": endian + "u2",
"I;16NS": endian + "i2",
"I;32": "<u4",
"I;32B": ">u4",
"I;32L": "<u4",
"I;32S": "<i4",
"I;32BS": ">i4",
"I;32LS": "<i4",
}.items():
modes[i16mode] = ModeDescriptor(i16mode, ("I",), "L", "L", typestr)
# 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"),
"LA": ("L", "L", ("L", "A"), "|u1"),
"La": ("L", "L", ("L", "a"), "|u1"),
"PA": ("RGB", "L", ("P", "A"), "|u1"),
}
if mode in modes:
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;16S": "<i2",
"I;16L": "<u2",
"I;16LS": "<i2",
"I;16B": ">u2",
"I;16BS": ">i2",
"I;16N": f"{endian}u2",
"I;16NS": f"{endian}i2",
"I;32": "<u4",
"I;32B": ">u4",
"I;32L": "<u4",
"I;32S": "<i4",
"I;32BS": ">i4",
"I;32LS": "<i4",
}
type_str = mapping_modes[mode]
return ModeDescriptor(mode, ("I",), "L", "L", type_str)

View File

@@ -4,6 +4,7 @@
# 2014-06-04 Initial version.
#
# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com>
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,30 +84,32 @@ class LutBuilder:
],
}
if op_name not in known_patterns:
msg = "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."""
@@ -134,17 +139,18 @@ class LutBuilder:
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", ""))
m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
if not m:
msg = 'Syntax error in pattern "' + p + '"'
raise Exception(msg)
@@ -158,10 +164,10 @@ class LutBuilder:
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
@@ -171,8 +177,8 @@ class LutBuilder:
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
@@ -181,7 +187,12 @@ class LutBuilder:
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:
@@ -189,7 +200,7 @@ class MorphOp:
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
@@ -202,10 +213,10 @@ class MorphOp:
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.
@@ -218,9 +229,9 @@ class MorphOp:
if image.mode != "L":
msg = "Image mode must be L"
raise ValueError(msg)
return _imagingmorph.match(bytes(self.lut), image.im.id)
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
@@ -229,9 +240,9 @@ class MorphOp:
if image.mode != "L":
msg = "Image mode must be L"
raise ValueError(msg)
return _imagingmorph.get_on_pixels(image.im.id)
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())
@@ -241,7 +252,7 @@ class MorphOp:
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:
msg = "No operator loaded"
@@ -249,6 +260,6 @@ class MorphOp:
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

View File

@@ -16,10 +16,13 @@
#
# 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 Literal, Protocol, cast, overload
from . import ExifTags, Image, ImagePalette
@@ -27,7 +30,7 @@ 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,7 +49,7 @@ 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
msg = "mode P support coming soon"
@@ -56,7 +59,7 @@ def _lut(image, lut):
lut = lut + lut + lut
return image.point(lut)
else:
msg = "not supported for this image mode"
msg = f"not supported for mode {image.mode}"
raise OSError(msg)
@@ -64,7 +67,13 @@ def _lut(image, lut):
# 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
@@ -96,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:
@@ -111,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]
@@ -122,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]
@@ -155,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
@@ -187,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 = []
@@ -198,46 +213,62 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi
blue = []
# Create the low-end values
for i in range(0, blackpoint):
red.append(black[0])
green.append(black[1])
blue.append(black[2])
for i in range(blackpoint):
red.append(rgb_black[0])
green.append(rgb_black[1])
blue.append(rgb_black[2])
# Create the mapping (2-color)
if mid is None:
range_map = range(0, whitepoint - blackpoint)
if rgb_mid is None:
range_map = range(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)
range_map1 = range(midpoint - blackpoint)
range_map2 = range(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])
for i in range(256 - whitepoint):
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.Resampling.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.
@@ -266,7 +297,9 @@ def contain(image, size, method=Image.Resampling.BICUBIC):
return image.resize(size, resample=method)
def cover(image, size, method=Image.Resampling.BICUBIC):
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.
@@ -295,7 +328,13 @@ def cover(image, size, method=Image.Resampling.BICUBIC):
return image.resize(size, resample=method)
def pad(image, size, method=Image.Resampling.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.
@@ -323,7 +362,9 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5
else:
out = Image.new(image.mode, size, color)
if resized.palette:
out.putpalette(resized.getpalette())
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))
@@ -333,7 +374,7 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5
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.
@@ -348,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.Resampling.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
@@ -371,7 +414,27 @@ def scale(image, factor, resample=Image.Resampling.BICUBIC):
return image.resize(size, resample)
def deform(image, deformer, resample=Image.Resampling.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.
@@ -387,7 +450,7 @@ def deform(image, deformer, resample=Image.Resampling.BILINEAR):
)
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
@@ -418,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
@@ -432,19 +499,26 @@ def expand(image, border=0, fill=0):
height = top + image.size[1] + bottom
color = _color(fill, image.mode)
if image.palette:
palette = ImagePalette.ImagePalette(palette=image.getpalette())
if isinstance(color, tuple):
mode = image.palette.mode
palette = ImagePalette.ImagePalette(mode, image.getpalette(mode))
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.putpalette(palette.palette, mode)
out.paste(image, (left, top))
return out
def fit(image, size, method=Image.Resampling.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.
@@ -478,13 +552,12 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5,
# kevin@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
@@ -521,8 +594,8 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(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)
@@ -530,7 +603,7 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(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).
@@ -540,7 +613,7 @@ def flip(image):
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
def grayscale(image):
def grayscale(image: Image.Image) -> Image.Image:
"""
Convert the image to grayscale.
@@ -550,20 +623,18 @@ 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)
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).
@@ -573,7 +644,7 @@ def mirror(image):
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.
@@ -581,19 +652,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 = []
@@ -605,7 +674,17 @@ def solarize(image, threshold=128):
return _lut(image, lut)
def exif_transpose(image, *, in_place=False):
@overload
def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ...
@overload
def exif_transpose(
image: Image.Image, *, in_place: Literal[False] = False
) -> Image.Image: ...
def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None:
"""
If an image has an EXIF Orientation tag, other than 1, transpose the image
accordingly, and remove the orientation data.
@@ -619,7 +698,7 @@ def exif_transpose(image, *, in_place=False):
"""
image.load()
image_exif = image.getexif()
orientation = image_exif.get(ExifTags.Base.Orientation)
orientation = image_exif.get(ExifTags.Base.Orientation, 1)
method = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
@@ -630,11 +709,11 @@ def exif_transpose(image, *, in_place=False):
8: Image.Transpose.ROTATE_90,
}.get(orientation)
if method is not None:
transposed_image = image.transpose(method)
if in_place:
image.im = transposed_image.im
image.pyaccess = None
image._size = transposed_image._size
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()
@@ -644,15 +723,24 @@ def exif_transpose(image, *, in_place=False):
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()
elif "XML:com.adobe.xmp" in exif_image.info:
for pattern in (
r'tiff:Orientation="([0-9])"',
r"<tiff:Orientation>([0-9])</tiff:Orientation>",
):
exif_image.info["XML:com.adobe.xmp"] = re.sub(
pattern, "", exif_image.info["XML:com.adobe.xmp"]
)
for key in ("XML:com.adobe.xmp", "xmp"):
if key in exif_image.info:
for pattern in (
r'tiff:Orientation="([0-9])"',
r"<tiff:Orientation>([0-9])</tiff:Orientation>",
):
value = exif_image.info[key]
if isinstance(value, str):
value = re.sub(pattern, "", value)
elif isinstance(value, tuple):
value = tuple(
re.sub(pattern.encode(), b"", v) for v in value
)
else:
value = re.sub(pattern.encode(), b"", value)
exif_image.info[key] = value
if not in_place:
return transposed_image
elif not in_place:
return image.copy()
return None

View File

@@ -15,11 +15,18 @@
#
# 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
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
TYPE_CHECKING = False
if TYPE_CHECKING:
from . import Image
class ImagePalette:
"""
@@ -33,23 +40,27 @@ class ImagePalette:
Defaults to an empty palette.
"""
def __init__(self, mode="RGB", palette=None):
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
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):
self._colors = None
def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
self._colors: dict[tuple[int, ...], int] | None = None
self._palette = palette
@property
def colors(self):
def colors(self) -> dict[tuple[int, ...], int]:
if self._colors is None:
mode_len = len(self.mode)
self._colors = {}
@@ -61,10 +72,10 @@ class ImagePalette:
return self._colors
@colors.setter
def colors(self, colors):
def colors(self, colors: dict[tuple[int, ...], int]) -> None:
self._colors = colors
def copy(self):
def copy(self) -> ImagePalette:
new = ImagePalette()
new.mode = self.mode
@@ -75,7 +86,7 @@ class ImagePalette:
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.
@@ -86,7 +97,7 @@ class ImagePalette:
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.
@@ -102,7 +113,37 @@ class ImagePalette:
# 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.
@@ -124,43 +165,24 @@ class ImagePalette:
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:
msg = "cannot allocate more than 256 colors"
raise ValueError(msg) 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:
msg = 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.
@@ -187,7 +209,7 @@ class ImagePalette:
# Internal
def raw(rawmode, data):
def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
palette = ImagePalette()
palette.rawmode = rawmode
palette.palette = data
@@ -199,65 +221,63 @@ 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:
msg = "cannot load palette"

View File

@@ -13,6 +13,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image

View File

@@ -15,6 +15,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
from io import BytesIO
@@ -22,50 +23,67 @@ from io import BytesIO
from . import Image
from ._util import is_path
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
from . import ImageFile
QBuffer: type
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:
qRgba: Callable[[int, int, int, int], int]
if qt_module == "PyQt6":
from PyQt6.QtCore import QBuffer, QIODevice
from PyQt6.QtCore import QBuffer, QByteArray, 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
from PySide6.QtCore import ( # type: ignore[assignment]
QBuffer,
QByteArray,
QIODevice,
)
from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment]
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: object
if qt_version == "6":
try:
qt_openmode = QIODevice.OpenModeFlag
qt_openmode = getattr(QIODevice, "OpenModeFlag")
except AttributeError:
qt_openmode = QIODevice.OpenMode
qt_openmode = getattr(QIODevice, "OpenMode")
else:
qt_openmode = QIODevice
buffer.open(qt_openmode.ReadWrite)
buffer.open(getattr(qt_openmode, "ReadWrite"))
# preserve alpha channel with png
# otherwise ppm is more friendly with Image.open
if im.hasAlphaChannel():
@@ -81,21 +99,11 @@ 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
"""
@@ -113,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
@@ -135,34 +141,32 @@ def _toqclass_helper(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
elif im.mode == "I;16" and hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+
format = getattr(qt_format, "Format_ARGB32")
elif im.mode == "I;16":
im = im.point(lambda i: i * 256)
format = qt_format.Format_Grayscale16
format = getattr(qt_format, "Format_Grayscale16")
else:
if exclusive_fp:
im.close()
@@ -179,7 +183,7 @@ def _toqclass_helper(im):
if qt_is_installed:
class ImageQt(QImage):
def __init__(self, im):
def __init__(self, im: Image.Image | str | QByteArray) -> None:
"""
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
class.
@@ -203,14 +207,13 @@ if qt_is_installed:
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

View File

@@ -14,6 +14,13 @@
#
##
from __future__ import annotations
from . import Image
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
class Iterator:
@@ -28,33 +35,38 @@ class Iterator:
:param im: An image object.
"""
def __init__(self, im):
def __init__(self, im: Image.Image) -> None:
if not hasattr(im, "seek"):
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.

View File

@@ -11,18 +11,22 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import abc
import os
import shutil
import subprocess
import sys
from shlex import quote
from typing import Any
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::
@@ -36,18 +40,15 @@ def register(viewer, order=1):
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.
@@ -67,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.
@@ -85,34 +86,37 @@ class Viewer:
# 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, path, **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
@@ -126,13 +130,26 @@ class WindowsViewer(Viewer):
format = "PNG"
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 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)
@@ -144,19 +161,23 @@ class MacViewer(Viewer):
format = "PNG"
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, path, **options):
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")
pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
executable = (not pyinstaller and sys.executable) or shutil.which("python3")
if executable:
subprocess.Popen(
[
@@ -173,13 +194,17 @@ if sys.platform == "darwin":
register(MacViewer)
class UnixViewer(Viewer):
class UnixViewer(abc.ABC, Viewer):
format = "PNG"
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)}"
return f"{command} {quote(file)}"
class XDGViewer(UnixViewer):
@@ -187,14 +212,16 @@ class XDGViewer(UnixViewer):
The freedesktop.org ``xdg-open`` command.
"""
def get_command_ex(self, file, **options):
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
command = executable = "xdg-open"
return command, executable
def show_file(self, path, **options):
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
@@ -205,16 +232,20 @@ class DisplayViewer(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]:
command = executable = "display"
if title:
command += f" -title {quote(title)}"
return command, executable
def show_file(self, path, **options):
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:
@@ -228,15 +259,17 @@ class DisplayViewer(UnixViewer):
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, **options):
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
@@ -244,15 +277,17 @@ class GmDisplayViewer(UnixViewer):
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, **options):
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
@@ -263,7 +298,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"
@@ -271,10 +308,12 @@ class XVViewer(UnixViewer):
command += f" -name {quote(title)}"
return command, executable
def show_file(self, path, **options):
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:
@@ -301,7 +340,7 @@ if sys.platform not in ("win32", "darwin"): # unixoids
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

View File

@@ -20,62 +20,82 @@
#
# 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):
msg = "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
@cached_property
def extrema(self) -> list[tuple[int, int]]:
"""
Min/max values for each band in the image.
def _getextrema(self):
"""Get 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):
n = 255
x = 0
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
res_min = i
break
for i in range(255, -1, -1):
if histogram[i]:
res_max = i
break
return res_min, res_max
v = []
for i in range(0, len(self.h), 256):
v.append(minmax(self.h[i:]))
return v
return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)]
def _getcount(self):
"""Get total number of pixels in each layer"""
@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)]
v = []
for i in range(0, len(self.h), 256):
v.append(functools.reduce(operator.add, self.h[i : i + 256]))
return v
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):
@@ -85,8 +105,9 @@ class Stat:
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):
@@ -96,16 +117,14 @@ class Stat:
v.append(sum2)
return v
def _getmean(self):
"""Get average pixel level for each layer"""
@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] if self.count[i] else 0 for i in self.bands]
v = []
for i in self.bands:
v.append(self.sum[i] / self.count[i])
return v
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:
@@ -119,30 +138,30 @@ class Stat:
v.append(j)
return v
def _getrms(self):
"""Get RMS for each layer"""
@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]) if self.count[i] else 0
for i in self.bands
]
v = []
for i in self.bands:
v.append(math.sqrt(self.sum2[i] / self.count[i]))
return v
@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]
if self.count[i]
else 0
)
for i in self.bands
]
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 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

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
from . import ImageFont
from ._typing import _Ink
class Text:
def __init__(
self,
text: str | bytes,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
mode: str = "RGB",
spacing: float = 4,
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
) -> None:
"""
:param text: String to be drawn.
:param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
:py:class:`~PIL.ImageFont.FreeTypeFont` instance,
:py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
``None``, the default font from :py:meth:`.ImageFont.load_default`
will be used.
:param mode: The image mode this will be used with.
:param spacing: The number of pixels between lines.
: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 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 `OpenType docs`_.
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.
"""
self.text = text
self.font = font or ImageFont.load_default()
self.mode = mode
self.spacing = spacing
self.direction = direction
self.features = features
self.language = language
self.embedded_color = False
self.stroke_width: float = 0
self.stroke_fill: _Ink | None = None
def embed_color(self) -> None:
"""
Use embedded color glyphs (COLR, CBDT, SBIX).
"""
if self.mode not in ("RGB", "RGBA"):
msg = "Embedded color supported only in RGB and RGBA modes"
raise ValueError(msg)
self.embedded_color = True
def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
"""
:param width: The width of the text stroke.
:param fill: Color to use for the text stroke when drawing. If not given, will
default to the ``fill`` parameter from
:py:meth:`.ImageDraw.ImageDraw.text`.
"""
self.stroke_width = width
self.stroke_fill = fill
def _get_fontmode(self) -> str:
if self.mode in ("1", "P", "I", "F"):
return "1"
elif self.embedded_color:
return "RGBA"
else:
return "L"
def get_length(self):
"""
Returns length (in pixels with 1/64 precision) of text.
This is the amount by which following text should be offset.
Text bounding box may extend past the length in some fonts,
e.g. when using italics or accents.
The result is returned as a float; it is a whole number if using basic layout.
Note that the sum of two lengths may not equal the length of a concatenated
string due to kerning. If you need to adjust for kerning, include the following
character and subtract its length.
For example, instead of::
hello = ImageText.Text("Hello", font).get_length()
world = ImageText.Text("World", font).get_length()
helloworld = ImageText.Text("HelloWorld", font).get_length()
assert hello + world == helloworld
use::
hello = (
ImageText.Text("HelloW", font).get_length() -
ImageText.Text("W", font).get_length()
) # adjusted for kerning
world = ImageText.Text("World", font).get_length()
helloworld = ImageText.Text("HelloWorld", font).get_length()
assert hello + world == helloworld
or disable kerning with (requires libraqm)::
hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
world = ImageText.Text("World", font, features=["-kern"]).get_length()
helloworld = ImageText.Text(
"HelloWorld", font, features=["-kern"]
).get_length()
assert hello + world == helloworld
:return: Either width for horizontal text, or height for vertical text.
"""
split_character = "\n" if isinstance(self.text, str) else b"\n"
if split_character in self.text:
msg = "can't measure length of multiline text"
raise ValueError(msg)
return self.font.getlength(
self.text,
self._get_fontmode(),
self.direction,
self.features,
self.language,
)
def _split(
self, xy: tuple[float, float], anchor: str | None, align: str
) -> list[tuple[tuple[float, float], str, str | bytes]]:
if anchor is None:
anchor = "lt" if self.direction == "ttb" else "la"
elif len(anchor) != 2:
msg = "anchor must be a 2 character string"
raise ValueError(msg)
lines = (
self.text.split("\n")
if isinstance(self.text, str)
else self.text.split(b"\n")
)
if len(lines) == 1:
return [(xy, anchor, self.text)]
if anchor[1] in "tb" and self.direction != "ttb":
msg = "anchor not supported for multiline text"
raise ValueError(msg)
fontmode = self._get_fontmode()
line_spacing = (
self.font.getbbox(
"A",
fontmode,
None,
self.features,
self.language,
self.stroke_width,
)[3]
+ self.stroke_width
+ self.spacing
)
top = xy[1]
parts = []
if self.direction == "ttb":
left = xy[0]
for line in lines:
parts.append(((left, top), anchor, line))
left += line_spacing
else:
widths = []
max_width: float = 0
for line in lines:
line_width = self.font.getlength(
line, fontmode, self.direction, self.features, self.language
)
widths.append(line_width)
max_width = max(max_width, line_width)
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
idx = -1
for line in lines:
left = xy[0]
idx += 1
width_difference = max_width - widths[idx]
# align by align parameter
if align in ("left", "justify"):
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center", "right" or "justify"'
raise ValueError(msg)
if (
align == "justify"
and width_difference != 0
and idx != len(lines) - 1
):
words = (
line.split(" ") if isinstance(line, str) else line.split(b" ")
)
if len(words) > 1:
# align left by anchor
if anchor[0] == "m":
left -= max_width / 2.0
elif anchor[0] == "r":
left -= max_width
word_widths = [
self.font.getlength(
word,
fontmode,
self.direction,
self.features,
self.language,
)
for word in words
]
word_anchor = "l" + anchor[1]
width_difference = max_width - sum(word_widths)
i = 0
for word in words:
parts.append(((left, top), word_anchor, word))
left += word_widths[i] + width_difference / (len(words) - 1)
i += 1
top += line_spacing
continue
# align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
parts.append(((left, top), anchor, line))
top += line_spacing
return parts
def get_bbox(
self,
xy: tuple[float, float] = (0, 0),
anchor: str | None = None,
align: str = "left",
) -> tuple[float, float, float, float]:
"""
Returns bounding box (in pixels) of text.
Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
precision. The bounding box includes extra margins for some fonts, e.g. italics
or accents.
:param xy: The anchor coordinates of the text.
:param anchor: The text anchor alignment. Determines the relative location of
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.
:param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
``"justify"`` determines the relative alignment of lines. Use the
``anchor`` parameter to specify the alignment to ``xy``.
:return: ``(left, top, right, bottom)`` bounding box
"""
bbox: tuple[float, float, float, float] | None = None
fontmode = self._get_fontmode()
for xy, anchor, line in self._split(xy, anchor, align):
bbox_line = self.font.getbbox(
line,
fontmode,
self.direction,
self.features,
self.language,
self.stroke_width,
anchor,
)
bbox_line = (
bbox_line[0] + xy[0],
bbox_line[1] + xy[1],
bbox_line[2] + xy[0],
bbox_line[3] + xy[1],
)
if bbox is None:
bbox = bbox_line
else:
bbox = (
min(bbox[0], bbox_line[0]),
min(bbox[1], bbox_line[1]),
max(bbox[2], bbox_line[2]),
max(bbox[3], bbox_line[3]),
)
if bbox is None:
return xy[0], xy[1], xy[0], xy[1]
return bbox

View File

@@ -24,51 +24,46 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import tkinter
from io import BytesIO
from typing import Any
from . import Image
from . import Image, ImageFile
TYPE_CHECKING = False
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, photo, id):
def _pyimagingtkcall(
command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType
) -> None:
tk = photo.tk
try:
tk.call(command, photo, id)
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, id)
tk.call(command, photo, repr(ptr))
# --------------------------------------------------------------------
@@ -95,27 +90,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)
@@ -127,15 +131,18 @@ class PhotoImage:
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
@@ -145,7 +152,7 @@ class PhotoImage:
"""
return str(self.__photo)
def width(self):
def width(self) -> int:
"""
Get the width of the image.
@@ -153,7 +160,7 @@ class PhotoImage:
"""
return self.__size[0]
def height(self):
def height(self) -> int:
"""
Get the height of the image.
@@ -161,7 +168,7 @@ class PhotoImage:
"""
return self.__size[1]
def paste(self, im):
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.
@@ -171,15 +178,14 @@ class PhotoImage:
the bitmap image.
"""
# 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
_pyimagingtkcall("PyImagingPhoto", self.__photo, block.id)
_pyimagingtkcall("PyImagingPhoto", self.__photo, ptr)
# --------------------------------------------------------------------
@@ -200,33 +206,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.
@@ -234,7 +238,7 @@ class BitmapImage:
"""
return self.__size[0]
def height(self):
def height(self) -> int:
"""
Get the height of the image.
@@ -242,7 +246,7 @@ class BitmapImage:
"""
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
@@ -253,31 +257,10 @@ class BitmapImage:
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
_pyimagingtkcall("PyImagingPhotoGet", photo, block.id)
_pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
return im
def _show(image, title):
"""Helper for the Image.show method."""
class UI(tkinter.Label):
def __init__(self, master, im):
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 not tkinter._default_root:
msg = "tkinter not initialized"
raise OSError(msg)
top = tkinter.Toplevel()
if title:
top.title(title)
UI(top, image).pack()

View File

@@ -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)
@@ -34,22 +48,42 @@ class AffineTransform(Transform):
Define an affine image transform.
This function takes a 6-tuple (a, b, c, d, e, f) which contain the first
two rows from an affine transform matrix. For each pixel (x, y) in the
output image, the new value is taken from a position (a x + b y + c,
d x + e y + f) in the input image, rounded to nearest pixel.
two rows from the inverse of an affine transform matrix. For each pixel
(x, y) in the output image, the new value is taken from a position (a x +
b y + c, d x + e y + f) 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:`~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.
from the inverse of an affine transform matrix.
"""
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):
"""
Define a transform to extract a subregion from an image.
@@ -63,7 +97,7 @@ 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`.
@@ -79,7 +113,7 @@ 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
@@ -94,7 +128,7 @@ 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.
"""

View File

@@ -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 @@ class Dib:
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
self.image.expose(handle_int)
def draw(self, handle, dst, src=None):
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 @@ class Dib:
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 @@ class Dib:
: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 @@ class Dib:
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 @@ class Dib:
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))

View File

@@ -13,7 +13,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
@@ -33,10 +33,12 @@ 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.
assert self.fp is not None
buffer = self.fp.read(100)
if b"\n" not in buffer:
msg = "not an IM file"
@@ -53,14 +55,14 @@ class ImtImageFile(ImageFile.ImageFile):
if not s:
break
if s == b"\x0C":
if s == b"\x0c":
# image data begins
self.tile = [
(
ImageFile._Tile(
"raw",
(0, 0) + self.size,
self.fp.tell() - len(buffer),
(self.mode, 0, 1),
self.mode,
)
]

View File

@@ -14,32 +14,24 @@
#
# See the README file for information on usage and redistribution.
#
import os
import tempfile
from __future__ import annotations
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
COMPRESSION = {1: "raw", 5: "jpeg"}
PAD = o8(0) * 4
#
# Helpers
def i(c):
return i32((PAD + c)[-4:])
def dump(c):
for i in c:
print("%02x" % i8(i), end=" ")
print()
def _i(c: bytes) -> int:
return i32((b"\0\0\0\0" + c)[-4:])
##
@@ -51,10 +43,10 @@ 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)
@@ -76,13 +68,13 @@ class IptcImageFile(ImageFile.ImageFile):
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 +94,20 @@ class IptcImageFile(ImageFile.ImageFile):
self.info[tag] = tagdata
# mode
layers = i8(self.info[(3, 60)][0])
component = i8(self.info[(3, 60)][1])
if (3, 65) in self.info:
id = i8(self.info[(3, 65)][0]) - 1
else:
id = 0
layers = self.info[(3, 60)][0]
component = self.info[(3, 60)][1]
if layers == 1 and not component:
self._mode = "L"
elif layers == 3 and component:
self._mode = "RGB"[id]
elif layers == 4 and component:
self._mode = "CMYK"[id]
band = None
else:
if layers == 3 and component:
self._mode = "RGB"
elif layers == 4 and component:
self._mode = "CMYK"
if (3, 65) in self.info:
band = self.info[(3, 65)][0] - 1
else:
band = 0
# size
self._size = self.getint((3, 20)), self.getint((3, 30))
@@ -128,47 +122,44 @@ class IptcImageFile(ImageFile.ImageFile):
# 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, band))
]
def load(self):
if len(self.tile) != 1 or self.tile[0][0] != "iptc":
return ImageFile.ImageFile.load(self)
def load(self) -> Image.core.PixelAccess | None:
if self.tile:
args = self.tile[0].args
assert isinstance(args, tuple)
compression, band = args
type, tile, box = self.tile[0]
self.fp.seek(self.tile[0].offset)
encoding, offset = tile
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":
# To simplify access to the extracted file,
# prepend a PPM header
o.write("P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
break
while size > 0:
s = self.fp.read(min(size, 8192))
if not s:
# Copy image data to temporary file
o = BytesIO()
if compression == "raw":
# To simplify access to the extracted file,
# prepend a PPM header
o.write(b"P5\n%d %d\n255\n" % self.size)
while True:
type, size = self.field()
if type != (8, 10):
break
o.write(s)
size -= len(s)
o.close()
while size > 0:
s = self.fp.read(min(size, 8192))
if not s:
break
o.write(s)
size -= len(s)
try:
with Image.open(outfile) as _im:
_im.load()
with Image.open(o) as _im:
if band is not None:
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
bands[band] = _im
_im = Image.merge(self.mode, bands)
else:
_im.load()
self.im = _im.im
finally:
try:
os.unlink(outfile)
except OSError:
pass
self.tile = []
return ImageFile.ImageFile.load(self)
Image.register_open(IptcImageFile.format, IptcImageFile)
@@ -176,7 +167,9 @@ Image.register_open(IptcImageFile.format, IptcImageFile)
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 +177,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 +199,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._tagdata[TiffImagePlugin.IPTC_NAA_CHUNK]
except KeyError:
pass
if data is None:
@@ -215,16 +210,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

View File

@@ -13,11 +13,19 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
import os
import struct
from typing import cast
from . import Image, ImageFile, _binary
from . import Image, ImageFile, ImagePalette, _binary
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import IO
class BoxReader:
@@ -26,13 +34,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,7 +50,7 @@ class BoxReader:
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):
msg = "Not enough data in header"
raise SyntaxError(msg)
@@ -56,32 +64,32 @@ class BoxReader:
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
@@ -94,7 +102,7 @@ class BoxReader:
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."""
@@ -104,15 +112,11 @@ def _parse_codestream(fp):
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"
@@ -123,20 +127,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
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)
if denom == 0:
return None
return (254 * num * (10**exp)) / (10000 * denom)
def _parse_jp2_header(fp):
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."""
@@ -154,18 +168,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"
@@ -177,12 +196,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:
@@ -193,7 +240,7 @@ def _parse_jp2_header(fp):
msg = "Malformed JP2 header"
raise SyntaxError(msg)
return size, mode, mimetype, dpi
return size, mode, mimetype, dpi, palette
##
@@ -204,30 +251,30 @@ 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._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:
msg = "not a JPEG 2000 file"
raise SyntaxError(msg)
if self.size is None or self.mode is None:
msg = "unable to determine size/mode"
raise SyntaxError(msg)
self._reduce = 0
self.layers = 0
@@ -248,7 +295,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
length = -1
self.tile = [
(
ImageFile._Tile(
"jpeg2k",
(0, 0) + self.size,
0,
@@ -256,11 +303,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
)
]
def _parse_comment(self):
hdr = self.fp.read(2)
length = _binary.i16be(hdr)
self.fp.seek(length - 2, os.SEEK_CUR)
def _parse_comment(self) -> None:
while True:
marker = self.fp.read(2)
if not marker:
@@ -278,18 +321,23 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
else:
self.fp.seek(length - 2, os.SEEK_CUR)
@property
def reduce(self):
@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
@@ -300,16 +348,16 @@ class Jpeg2KImageFile(ImageFile.ImageFile):
# 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):
return (
prefix[:4] == b"\xff\x4f\xff\x51"
or prefix[:12] == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(
(b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a")
)
@@ -317,11 +365,13 @@ def _accept(prefix):
# Save support
def _save(im, fp, filename):
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
# Get the keyword arguments
info = im.encoderinfo
if filename.endswith(".j2k") or info.get("no_jp2", False):
if isinstance(filename, str):
filename = filename.encode()
if filename.endswith(b".j2k") or info.get("no_jp2", False):
kind = "j2k"
else:
kind = "jp2"
@@ -334,10 +384,7 @@ 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
)
):
msg = "quality_layers must be a sequence of numbers"
@@ -382,7 +429,7 @@ def _save(im, fp, filename):
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)])
# ------------------------------------------------------------

View File

@@ -31,6 +31,8 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import array
import io
import math
@@ -48,16 +50,22 @@ from ._binary import o8
from ._binary import o16be as o16
from .JpegPresets import presets
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO, Any
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.
@@ -65,12 +73,12 @@ 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))
if marker == 0xFFE0 and s[:4] == b"JFIF":
if marker == 0xFFE0 and s.startswith(b"JFIF"):
# extract JFIF information
self.info["jfif"] = version = i16(s, 5) # version
self.info["jfif_version"] = divmod(version, 256)
@@ -83,17 +91,24 @@ 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.startswith(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 == 0xFFE2 and s[:5] == b"FPXR\0":
elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
self.info["xmp"] = s.split(b"\x00", 1)[1]
elif marker == 0xFFE2 and s.startswith(b"FPXR\0"):
# extract FlashPix information (incomplete)
self.info["flashpix"] = s # FIXME: value will change
elif marker == 0xFFE2 and s[:12] == b"ICC_PROFILE\0":
elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"):
# Since an ICC profile can be larger than the maximum size of
# a JPEG marker (64K), we need provisions to split it into
# multiple markers. The format defined by the ICC specifies
@@ -106,7 +121,7 @@ def APP(self, marker):
# reassemble the profile, rather than assuming that the APP2
# markers appear in the correct sequence.
self.icclist.append(s)
elif marker == 0xFFED and s[:14] == b"Photoshop 3.0\x00":
elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"):
# parse the image resource block
offset = 14
photoshop = self.info.setdefault("photoshop", {})
@@ -126,19 +141,20 @@ 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:
break # insufficient data
elif marker == 0xFFEE and s[:5] == b"Adobe":
elif marker == 0xFFEE and s.startswith(b"Adobe"):
self.info["adobe"] = i16(s, 5)
# extract Adobe custom properties
try:
@@ -147,46 +163,15 @@ def APP(self, marker):
pass
else:
self.info["adobe_transform"] = adobe_transform
elif marker == 0xFFE2 and s[:4] == b"MPF\0":
elif marker == 0xFFE2 and s.startswith(b"MPF\0"):
# extract MPO information
self.info["mp"] = s[4:]
# offset is current location minus buffer size
# 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 (
struct.error,
KeyError,
SyntaxError,
TypeError,
ValueError,
ZeroDivisionError,
):
# struct.error for truncated EXIF
# KeyError for dpi not included
# SyntaxError for invalid/unreadable EXIF
# ValueError or TypeError for dpi being an invalid float
# ZeroDivisionError for invalid dpi rational value
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
@@ -197,7 +182,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
@@ -208,6 +193,8 @@ 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)
if self._im is not None and self.size != self.im.size:
self._im = None
self.bits = s[0]
if self.bits != 8:
@@ -232,9 +219,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
@@ -247,7 +232,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.
@@ -341,9 +326,9 @@ MARKER = {
}
def _accept(prefix):
def _accept(prefix: bytes) -> bool:
# Magic number was taken from https://en.wikipedia.org/wiki/JPEG
return prefix[:3] == b"\xFF\xD8\xFF"
return prefix.startswith(b"\xff\xd8\xff")
##
@@ -354,25 +339,26 @@ 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):
msg = "not a JPEG file"
raise SyntaxError(msg)
s = b"\xFF"
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]
@@ -392,11 +378,13 @@ class JpegImageFile(ImageFile.ImageFile):
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)
@@ -405,7 +393,16 @@ class JpegImageFile(ImageFile.ImageFile):
msg = "no marker found"
raise SyntaxError(msg)
def load_read(self, read_bytes):
self._read_dpi_from_exif()
def __getstate__(self) -> list[Any]:
return super().__getstate__() + [self.layers, self.layer]
def __setstate__(self, state: list[Any]) -> None:
self.layers, self.layer = state[6:]
super().__setstate__(state)
def load_read(self, read_bytes: int) -> bytes:
"""
internal: read more image data
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
@@ -417,22 +414,25 @@ class JpegImageFile(ImageFile.ImageFile):
# Premature EOF.
# Pretend file is finished adding EOI marker
self._ended = True
return b"\xFF\xD9"
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
a = mode, ""
@@ -442,6 +442,7 @@ class JpegImageFile(ImageFile.ImageFile):
for s in [8, 4, 2, 1]:
if scale >= s:
break
assert e is not None
e = (
e[0],
e[1],
@@ -451,13 +452,13 @@ class JpegImageFile(ImageFile.ImageFile):
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):
def load_djpeg(self) -> None:
# ALTERNATIVE: handle JPEGs via the IJG command line utilities
f, path = tempfile.mkstemp()
@@ -488,35 +489,49 @@ class JpegImageFile(ImageFile.ImageFile):
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 getxmp(self):
"""
Returns a dictionary containing the XMP tags.
Requires defusedxml to be installed.
:returns: XMP tags in a dictionary.
"""
for segment, content in self.applist:
if segment == "APP1":
marker, xmp_tags = content.split(b"\x00")[:2]
if marker == b"http://ns.adobe.com/xap/1.0/":
return self._getxmp(xmp_tags)
return {}
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.
@@ -529,7 +544,7 @@ def _getmp(self):
return None
file_contents = io.BytesIO(data)
head = file_contents.read(8)
endianness = ">" if head[:4] == b"\x4d\x4d\x00\x2a" else "<"
endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<"
# process dictionary
from . import TiffImagePlugin
@@ -551,7 +566,7 @@ def _getmp(self):
mpentries = []
try:
rawmpentries = mp[0xB002]
for entrynum in range(0, quant):
for entrynum in range(quant):
unpackedentry = struct.unpack_from(
f"{endianness}LLLHH", rawmpentries, entrynum * 16
)
@@ -624,7 +639,7 @@ samplings = {
# fmt: on
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.
@@ -632,13 +647,13 @@ 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)
@@ -691,7 +706,11 @@ def _save(im, fp, filename):
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):
@@ -719,13 +738,14 @@ def _save(im, fp, filename):
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:
msg = "Invalid quantization table"
raise ValueError(msg) from e
else:
qtables[idx] = list(table)
qtables[idx] = list(table_array)
return qtables
if qtables == "keep":
@@ -738,19 +758,27 @@ def _save(im, fp, filename):
extra = info.get("extra", b"")
MAX_BYTES_IN_MARKER = 65533
icc_profile = info.get("icc_profile")
if icc_profile:
ICC_OVERHEAD_LEN = 14
MAX_DATA_BYTES_IN_MARKER = MAX_BYTES_IN_MARKER - ICC_OVERHEAD_LEN
if xmp := info.get("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
if icc_profile := info.get("icc_profile"):
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 = o16(2 + ICC_OVERHEAD_LEN + len(marker))
size = o16(2 + overhead_len + len(marker))
extra += (
b"\xFF\xE2"
b"\xff\xe2"
+ size
+ b"ICC_PROFILE\0"
+ o8(i)
@@ -781,10 +809,12 @@ def _save(im, fp, filename):
progressive,
info.get("smooth", 0),
optimize,
info.get("keep_rgb", False),
info.get("streamtype", 0),
dpi[0],
dpi[1],
dpi,
subsampling,
info.get("restart_marker_blocks", 0),
info.get("restart_marker_rows", 0),
qtables,
comment,
extra,
@@ -795,7 +825,6 @@ def _save(im, fp, filename):
# in a shot. Guessing on the size, at im.size bytes. (raw pixel size is
# channels*size, this is a value that's been used in a django patch.
# https://github.com/matthewwithanm/django-imagekit/issues/50
bufsize = 0
if optimize or progressive:
# CMYK can be bigger
if im.mode == "CMYK":
@@ -812,28 +841,26 @@ def _save(im, fp, filename):
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)
bufsize = max(len(exif) + 5, len(extra) + 1)
ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize)
def _save_cjpeg(im, fp, filename):
# ALTERNATIVE: handle JPEGs via the IJG command line utilities.
tempfile = im._dump()
subprocess.check_call(["cjpeg", "-outfile", filename, tempfile])
try:
os.unlink(tempfile)
except OSError:
pass
ImageFile._save(
im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize
)
##
# 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

View File

@@ -63,6 +63,8 @@ https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/li
"""
from __future__ import annotations
# fmt: off
presets = {
'web_low': {'subsampling': 2, # "4:2:0"

View File

@@ -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.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04")
##
@@ -33,23 +34,23 @@ 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:
msg = "not an McIdas area file"
raise SyntaxError(msg)
self.area_descriptor_raw = s
self.area_descriptor = w = [0] + list(struct.unpack("!64i", s))
self.area_descriptor = w = [0, *struct.unpack("!64i", s)]
# get mode
if w[11] == 1:
mode = rawmode = "L"
elif w[11] == 2:
# FIXME: add memory map support
mode = "I"
rawmode = "I;16B"
mode = rawmode = "I;16B"
elif w[11] == 4:
# FIXME: add memory map support
mode = "I"
@@ -64,7 +65,9 @@ class McIdasImageFile(ImageFile.ImageFile):
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))
]
# --------------------------------------------------------------------

View File

@@ -15,7 +15,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import olefile
@@ -25,8 +25,8 @@ from . import Image, TiffImagePlugin
# --------------------------------------------------------------------
def _accept(prefix):
return prefix[:8] == olefile.MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(olefile.MAGIC)
##
@@ -38,7 +38,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
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
@@ -51,10 +51,11 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
# 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].endswith(".ACI") and path[1] == "Image"
]
# if we didn't find any images, this is probably not
# an MIC file.
@@ -62,35 +63,33 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
msg = "not an MIC file; no image entries"
raise SyntaxError(msg)
self.frame = None
self.frame = -1
self._n_frames = len(self.images)
self.is_animated = self._n_frames > 1
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:
msg = "no such frame"
raise EOFError(msg) from e
filename = self.images[frame]
self.fp = self.ole.openstream(filename)
TiffImagePlugin.TiffImageFile._open(self)
self.frame = frame
def tell(self):
def tell(self) -> int:
return self.frame
def close(self):
def close(self) -> None:
self.__fp.close()
self.ole.close()
super().close()
def __exit__(self, *args):
def __exit__(self, *args: object) -> None:
self.__fp.close()
self.ole.close()
super().__exit__()

View File

@@ -12,46 +12,47 @@
#
# 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:
self.bits = 0
continue
self.bitbuffer = (self.bitbuffer << 8) + c
self.bitbuffer = (self.bitbuffer << 8) + self.next()
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.startswith(b"\x00\x00\x01\xb3")
##
# Image plugin for MPEG streams. This plugin can identify a stream,
# but it cannot read it.
@@ -61,9 +62,10 @@ class MpegImageFile(ImageFile.ImageFile):
format = "MPEG"
format_description = "MPEG"
def _open(self):
s = BitStream(self.fp)
def _open(self) -> None:
assert self.fp is not None
s = BitStream(self.fp)
if s.read(32) != 0x1B3:
msg = "not an MPEG file"
raise SyntaxError(msg)
@@ -75,7 +77,7 @@ class MpegImageFile(ImageFile.ImageFile):
# --------------------------------------------------------------------
# Registry stuff
Image.register_open(MpegImageFile.format, MpegImageFile)
Image.register_open(MpegImageFile.format, MpegImageFile, _accept)
Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"])

View File

@@ -17,49 +17,47 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import itertools
import os
import struct
from typing import IO, Any, cast
from . import (
ExifTags,
Image,
ImageFile,
ImageSequence,
JpegImagePlugin,
TiffImagePlugin,
)
from ._binary import i16be as i16
from ._binary import o32le
# def _accept(prefix):
# return JpegImagePlugin._accept(prefix)
from ._util import DeferredError
def _save(im, fp, filename):
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
JpegImagePlugin._save(im, fp, filename)
def _save_all(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:
try:
animated = im.is_animated
except AttributeError:
animated = False
if not animated:
_save(im, fp, filename)
return
if not append_images and not getattr(im, "is_animated", False):
_save(im, fp, filename)
return
mpf_offset = 28
offsets = []
for imSequence in itertools.chain([im], append_images):
for im_frame in ImageSequence.Iterator(imSequence):
offsets: list[int] = []
im_sequences = [im, *append_images]
total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences)
for im_sequence in im_sequences:
for im_frame in ImageSequence.Iterator(im_sequence):
if not offsets:
# APP2 marker
ifd_length = 66 + 16 * total
im_frame.encoderinfo["extra"] = (
b"\xFF\xE2" + struct.pack(">H", 6 + 82) + b"MPF\0" + b" " * 82
b"\xff\xe2"
+ struct.pack(">H", 6 + ifd_length)
+ b"MPF\0"
+ b" " * ifd_length
)
exif = im_frame.encoderinfo.get("exif")
if isinstance(exif, Image.Exif):
@@ -71,7 +69,9 @@ def _save_all(im, fp, filename):
JpegImagePlugin._save(im_frame, fp, filename)
offsets.append(fp.tell())
else:
encoderinfo = im_frame._attach_default_encoderinfo(im)
im_frame.save(fp, "JPEG")
im_frame.encoderinfo = encoderinfo
offsets.append(fp.tell() - offsets[-1])
ifd = TiffImagePlugin.ImageFileDirectory_v2()
@@ -92,7 +92,7 @@ def _save_all(im, fp, filename):
ifd[0xB002] = mpentries
fp.seek(mpf_offset)
fp.write(b"II\x2A\x00" + o32le(8) + ifd.tobytes(8))
fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8))
fp.seek(0, os.SEEK_END)
@@ -105,14 +105,16 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
format_description = "MPO (CIPA DC-007)"
_close_exclusive_fp_after_loading = False
def _open(self):
def _open(self) -> 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):
self._initial_size = self.size
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]
@@ -130,43 +132,45 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
# for now we can only handle reading and individual frame extraction
self.readonly = 1
def load_seek(self, pos):
def load_seek(self, pos: int) -> None:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self._fp.seek(pos)
def seek(self, frame):
def seek(self, frame: int) -> None:
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.fp = self._fp
self.offset = self.__mpoffsets[frame]
original_exif = self.info.get("exif")
if "exif" in self.info:
del self.info["exif"]
self.fp.seek(self.offset + 2) # skip SOI marker
segment = self.fp.read(2)
if not segment:
if not self.fp.read(2):
msg = "No data found for frame"
raise ValueError(msg)
self._size = self._initial_size
if i16(segment) == 0xFFE1: # APP1
n = i16(self.fp.read(2)) - 2
self.info["exif"] = ImageFile._safe_read(self.fp, n)
self.fp.seek(self.offset)
JpegImagePlugin.JpegImageFile._open(self)
if self.info.get("exif") != original_exif:
self._reload_exif()
mptype = self.mpinfo[0xB002][frame]["Attribute"]["MPType"]
if mptype.startswith("Large Thumbnail"):
exif = self.getexif().get_ifd(ExifTags.IFD.Exif)
if 40962 in exif and 40963 in exif:
self._size = (exif[40962], exif[40963])
elif "exif" in self.info:
del self.info["exif"]
self._reload_exif()
self.tile = [("jpeg", (0, 0) + self.size, self.offset, (self.mode, ""))]
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
@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.
@@ -178,8 +182,9 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile):
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
# ---------------------------------------------------------------------

View File

@@ -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,8 +36,8 @@ from ._binary import o16le as o16
# read MSP files
def _accept(prefix):
return prefix[:4] in [b"DanM", b"LinS"]
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"DanM", b"LinS"))
##
@@ -47,8 +49,10 @@ 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):
msg = "not an MSP file"
@@ -65,10 +69,10 @@ class MspImageFile(ImageFile.ImageFile):
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))]
if s.startswith(b"DanM"):
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,9 @@ 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))
try:
@@ -146,7 +152,7 @@ class MspDecoder(ImageFile.PyDecoder):
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 -1, 0
@@ -158,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder)
# 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":
msg = f"cannot write mode {im.mode} as MSP"
raise OSError(msg)
@@ -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")])
#

View File

@@ -14,11 +14,16 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import sys
from typing import IO
from . import EpsImagePlugin
TYPE_CHECKING = False
##
# Simple PostScript graphics interface.
@@ -26,18 +31,15 @@ from . import EpsImagePlugin
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 +53,32 @@ class PSDraw:
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,7 +86,7 @@ class PSDraw:
"""
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.
@@ -91,25 +95,29 @@ class PSDraw:
"""
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,)))
def image(self, box, im, dpi=None):
if TYPE_CHECKING:
from . import Image
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,7 +138,7 @@ class PSDraw:
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")

View File

@@ -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,15 +24,15 @@ 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[:1] == b"#":
if s.startswith(b"#"):
continue
if len(s) > 100:
msg = "bad palette file"
@@ -43,9 +46,9 @@ class PaletteFile:
g = b = r
if 0 <= i <= 255:
self.palette[i] = o8(r) + o8(g) + o8(b)
palette[i] = o8(r) + o8(g) + o8(b)
self.palette = b"".join(self.palette)
self.palette = b"".join(palette)
def getpalette(self):
def getpalette(self) -> tuple[bytes, str]:
return self.palette, self.rawmode

View File

@@ -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 @@ _Palm8BitColormapValues = (
# 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,11 +114,8 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00}
# (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
rawmode = "P"
bpp = 8
version = 1
@@ -124,24 +124,25 @@ 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:
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":
@@ -168,11 +169,11 @@ def _save(im, fp, filename):
compression_type = _COMPRESSION_TYPES["none"]
flags = 0
if im.mode == "P" and "custom-colormap" in im.info:
flags = flags & _FLAGS["custom-colormap"]
colormapsize = 4 * 256 + 2
colormapmode = im.palette.mode
colormap = im.getdata().getpalette()
if im.mode == "P":
flags |= _FLAGS["custom-colormap"]
colormap = im.im.getpalette()
colors = len(colormap) // 3
colormapsize = 4 * colors + 2
else:
colormapsize = 0
@@ -191,25 +192,16 @@ def _save(im, fp, filename):
# now write colormap if necessary
if colormapsize > 0:
fp.write(o16b(256))
for i in range(256):
if colormapsize:
fp.write(o16b(colors))
for i in range(colors):
fp.write(o8(i))
if colormapmode == "RGB":
fp.write(
o8(colormap[3 * i])
+ o8(colormap[3 * i + 1])
+ o8(colormap[3 * i + 2])
)
elif colormapmode == "RGBA":
fp.write(
o8(colormap[4 * i])
+ o8(colormap[4 * i + 1])
+ o8(colormap[4 * i + 2])
)
fp.write(colormap[3 * i : 3 * i + 3])
# 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()

View File

@@ -13,7 +13,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
from . import Image, ImageFile
@@ -27,12 +27,14 @@ class PcdImageFile(ImageFile.ImageFile):
format = "PCD"
format_description = "Kodak PhotoCD"
def _open(self):
def _open(self) -> None:
# rough
self.fp.seek(2048)
s = self.fp.read(2048)
assert self.fp is not None
if s[:4] != b"PCD_":
self.fp.seek(2048)
s = self.fp.read(1539)
if not s.startswith(b"PCD_"):
msg = "not a PCD file"
raise SyntaxError(msg)
@@ -41,17 +43,21 @@ class PcdImageFile(ImageFile.ImageFile):
if orientation == 1:
self.tile_post_rotate = 90
elif orientation == 3:
self.tile_post_rotate = -90
self.tile_post_rotate = 270
self._mode = "RGB"
self._size = 768, 512 # FIXME: not correct for rotated images!
self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)]
self._size = (512, 768) if orientation in (1, 3) else (768, 512)
self.tile = [ImageFile._Tile("pcd", (0, 0, 768, 512), 96 * 2048)]
def load_end(self):
def load_prepare(self) -> None:
if self._im is None and self.tile_post_rotate:
self.im = Image.core.new(self.mode, (768, 512))
ImageFile.ImageFile.load_prepare(self)
def load_end(self) -> None:
if self.tile_post_rotate:
# Handle rotated PCDs
self.im = self.im.rotate(self.tile_post_rotate)
self._size = self.im.size
self.im = self.rotate(self.tile_post_rotate, expand=True).im
#

View File

@@ -15,6 +15,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import io
@@ -25,6 +26,11 @@ from ._binary import i16le as l16
from ._binary import i32be as b32
from ._binary import i32le as l32
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable
from typing import BinaryIO
# --------------------------------------------------------------------
# declarations
@@ -40,7 +46,7 @@ PCF_SWIDTHS = 1 << 6
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 +54,7 @@ BYTES_PER_ROW = [
]
def sz(s, o):
def sz(s: bytes, o: int) -> bytes:
return s[o : s.index(b"\0", o)]
@@ -57,7 +63,7 @@ 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))
@@ -103,7 +109,9 @@ class PcfFontFile(FontFile.FontFile):
bitmaps[ix],
)
def _getformat(self, tag):
def _getformat(
self, tag: int
) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]:
format, size, offset = self.toc[tag]
fp = self.fp
@@ -118,7 +126,7 @@ class PcfFontFile(FontFile.FontFile):
return fp, format, i16, i32
def _load_properties(self):
def _load_properties(self) -> dict[bytes, bytes | int]:
#
# font properties
@@ -129,27 +137,24 @@ class PcfFontFile(FontFile.FontFile):
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)
@@ -182,12 +187,12 @@ class PcfFontFile(FontFile.FontFile):
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))
@@ -196,13 +201,9 @@ class PcfFontFile(FontFile.FontFile):
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)]
bitmap_sizes = []
for i in range(4):
bitmap_sizes.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
@@ -218,6 +219,7 @@ class PcfFontFile(FontFile.FontFile):
if bitorder:
mode = "1"
bitmaps = []
for i in range(nbitmaps):
xsize, ysize = metrics[i][:2]
b, e = offsets[i : i + 2]
@@ -227,7 +229,7 @@ class PcfFontFile(FontFile.FontFile):
return bitmaps
def _load_encoding(self):
def _load_encoding(self) -> list[int | None]:
fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS)
first_col, last_col = i16(fp.read(2)), i16(fp.read(2))
@@ -238,7 +240,7 @@ class PcfFontFile(FontFile.FontFile):
nencoding = (last_col - first_col + 1) * (last_row - first_row + 1)
# map character code to bitmap index
encoding = [None] * min(256, nencoding)
encoding: list[int | None] = [None] * min(256, nencoding)
encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)]

View File

@@ -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,8 +38,8 @@ from ._binary import o16le as o16
logger = logging.getLogger(__name__)
def _accept(prefix):
return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 2 and prefix[0] == 10 and prefix[1] in [0, 2, 3, 5]
##
@@ -48,9 +50,11 @@ class PcxImageFile(ImageFile.ImageFile):
format = "PCX"
format_description = "Paintbrush"
def _open(self):
def _open(self) -> None:
# header
s = self.fp.read(128)
assert self.fp is not None
s = self.fp.read(68)
if not _accept(s):
msg = "not a PCX file"
raise SyntaxError(msg)
@@ -62,6 +66,8 @@ class PcxImageFile(ImageFile.ImageFile):
raise SyntaxError(msg)
logger.debug("BBox: %s %s %s %s", *bbox)
offset = self.fp.tell() + 60
# format
version = s[1]
bits = s[3]
@@ -82,7 +88,7 @@ class PcxImageFile(ImageFile.ImageFile):
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,14 +97,13 @@ class PcxImageFile(ImageFile.ImageFile):
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"
break
if mode == "P":
self.palette = ImagePalette.raw("RGB", s[1:])
self.fp.seek(128)
elif version == 5 and bits == 8 and planes == 3:
mode = "RGB"
@@ -124,7 +129,7 @@ class PcxImageFile(ImageFile.ImageFile):
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, offset, (rawmode, planes * stride))]
# --------------------------------------------------------------------
@@ -140,7 +145,7 @@ SAVE = {
}
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:
@@ -182,7 +187,7 @@ def _save(im, fp, filename):
+ o16(dpi[0])
+ o16(dpi[1])
+ b"\0" * 24
+ b"\xFF" * 24
+ b"\xff" * 24
+ b"\0"
+ o8(planes)
+ o16(stride)
@@ -194,7 +199,9 @@ 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
@@ -203,7 +210,7 @@ def _save(im, fp, filename):
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)

View File

@@ -19,13 +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__, features
from . import Image, ImageFile, ImageSequence, PdfParser, features
#
# --------------------------------------------------------------------
@@ -38,7 +40,7 @@ from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features
# 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)
@@ -46,7 +48,12 @@ def _save_all(im, fp, filename):
# (Internal) Image save plugin for the PDF format.
def _write_image(im, filename, existing_pdf, image_refs):
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).
@@ -59,10 +66,10 @@ def _write_image(im, filename, existing_pdf, image_refs):
width, height = im.size
dict_obj = {"BitsPerComponent": 8}
dict_obj: dict[str, Any] = {"BitsPerComponent": 8}
if im.mode == "1":
if features.check("libtiff"):
filter = "CCITTFaxDecode"
decode_filter = "CCITTFaxDecode"
dict_obj["BitsPerComponent"] = 1
params = PdfParser.PdfArray(
[
@@ -77,26 +84,27 @@ def _write_image(im, filename, existing_pdf, image_refs):
]
)
else:
filter = "DCTDecode"
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "L":
filter = "DCTDecode"
decode_filter = "DCTDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceGray")
procset = "ImageB" # grayscale
elif im.mode == "LA":
filter = "JPXDecode"
decode_filter = "JPXDecode"
# params = f"<< /Predictor 15 /Columns {width-2} >>"
procset = "ImageB" # grayscale
dict_obj["SMaskInData"] = 1
elif im.mode == "P":
filter = "ASCIIHexDecode"
decode_filter = "ASCIIHexDecode"
palette = im.getpalette()
assert palette is not None
dict_obj["ColorSpace"] = [
PdfParser.PdfName("Indexed"),
PdfParser.PdfName("DeviceRGB"),
255,
len(palette) // 3 - 1,
PdfParser.PdfBinary(palette),
]
procset = "ImageI" # indexed color
@@ -108,15 +116,15 @@ def _write_image(im, filename, existing_pdf, image_refs):
image_ref = _write_image(smask, filename, existing_pdf, image_refs)[0]
dict_obj["SMask"] = image_ref
elif im.mode == "RGB":
filter = "DCTDecode"
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "RGBA":
filter = "JPXDecode"
decode_filter = "JPXDecode"
procset = "ImageC" # color images
dict_obj["SMaskInData"] = 1
elif im.mode == "CMYK":
filter = "DCTDecode"
decode_filter = "DCTDecode"
dict_obj["ColorSpace"] = PdfParser.PdfName("DeviceCMYK")
procset = "ImageC" # color images
decode = [1, 0, 1, 0, 1, 0, 1, 0]
@@ -129,9 +137,9 @@ def _write_image(im, filename, existing_pdf, image_refs):
op = io.BytesIO()
if filter == "ASCIIHexDecode":
ImageFile._save(im, op, [("hex", (0, 0) + im.size, 0, im.mode)])
elif filter == "CCITTFaxDecode":
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",
@@ -139,21 +147,22 @@ def _write_image(im, filename, existing_pdf, image_refs):
# use a single strip
strip_size=math.ceil(width / 8) * height,
)
elif filter == "DCTDecode":
elif decode_filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
elif filter == "JPXDecode":
elif decode_filter == "JPXDecode":
del dict_obj["BitsPerComponent"]
Image.SAVE["JPEG2000"](im, op, filename)
else:
msg = f"unsupported PDF filter ({filter})"
msg = f"unsupported PDF filter ({decode_filter})"
raise ValueError(msg)
stream = op.getvalue()
if filter == "CCITTFaxDecode":
filter: PdfParser.PdfArray | PdfParser.PdfName
if decode_filter == "CCITTFaxDecode":
stream = stream[8:]
filter = PdfParser.PdfArray([PdfParser.PdfName(filter)])
filter = PdfParser.PdfArray([PdfParser.PdfName(decode_filter)])
else:
filter = PdfParser.PdfName(filter)
filter = PdfParser.PdfName(decode_filter)
image_ref = image_refs.pop(0)
existing_pdf.write_obj(
@@ -172,12 +181,15 @@ def _write_image(im, filename, existing_pdf, image_refs):
return image_ref, procset
def _save(im, fp, filename, save_all=False):
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")
dpi = im.encoderinfo.get("dpi")
if dpi:
@@ -187,9 +199,9 @@ def _save(im, fp, filename, save_all=False):
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,
@@ -209,7 +221,7 @@ def _save(im, fp, filename, save_all=False):
existing_pdf.start_writing()
existing_pdf.write_header()
existing_pdf.write_comment(f"created by Pillow {__version__} PDF driver")
existing_pdf.write_comment("created by Pillow PDF driver")
#
# pages
@@ -226,12 +238,7 @@ def _save(im, fp, filename, save_all=False):
for im in ims:
im_number_of_pages = 1
if save_all:
try:
im_number_of_pages = im.n_frames
except AttributeError:
# Image format does not have n_frames.
# It is a single frame image
pass
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))
@@ -248,7 +255,9 @@ def _save(im, fp, filename, save_all=False):
page_number = 0
for im_sequence in ims:
im_pages = ImageSequence.Iterator(im_sequence) if save_all else [im_sequence]
im_pages: ImageSequence.Iterator | list[Image.Image] = (
ImageSequence.Iterator(im_sequence) if save_all else [im_sequence]
)
for im in im_pages:
image_ref, procset = _write_image(im, filename, existing_pdf, image_refs)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import calendar
import codecs
import collections
@@ -6,24 +8,33 @@ import os
import re
import time
import zlib
from typing import Any, NamedTuple
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO
_DictBase = collections.UserDict[str | bytes, Any]
else:
_DictBase = collections.UserDict
# 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")
PDFDocEncoding = {
0x16: "\u0017",
0x18: "\u02D8",
0x19: "\u02C7",
0x1A: "\u02C6",
0x1B: "\u02D9",
0x1C: "\u02DD",
0x1D: "\u02DB",
0x1E: "\u02DA",
0x1F: "\u02DC",
0x18: "\u02d8",
0x19: "\u02c7",
0x1A: "\u02c6",
0x1B: "\u02d9",
0x1C: "\u02dd",
0x1D: "\u02db",
0x1E: "\u02da",
0x1F: "\u02dc",
0x80: "\u2022",
0x81: "\u2020",
0x82: "\u2021",
@@ -33,33 +44,33 @@ PDFDocEncoding = {
0x86: "\u0192",
0x87: "\u2044",
0x88: "\u2039",
0x89: "\u203A",
0x89: "\u203a",
0x8A: "\u2212",
0x8B: "\u2030",
0x8C: "\u201E",
0x8D: "\u201C",
0x8E: "\u201D",
0x8C: "\u201e",
0x8D: "\u201c",
0x8E: "\u201d",
0x8F: "\u2018",
0x90: "\u2019",
0x91: "\u201A",
0x91: "\u201a",
0x92: "\u2122",
0x93: "\uFB01",
0x94: "\uFB02",
0x93: "\ufb01",
0x94: "\ufb02",
0x95: "\u0141",
0x96: "\u0152",
0x97: "\u0160",
0x98: "\u0178",
0x99: "\u017D",
0x99: "\u017d",
0x9A: "\u0131",
0x9B: "\u0142",
0x9C: "\u0153",
0x9D: "\u0161",
0x9E: "\u017E",
0xA0: "\u20AC",
0x9E: "\u017e",
0xA0: "\u20ac",
}
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 +84,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 +138,13 @@ class XrefTable:
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,34 +155,32 @@ class XrefTable:
elif key in self.deleted_entries:
generation = self.deleted_entries[key]
else:
msg = (
"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
@@ -175,7 +190,7 @@ class XrefTable:
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:
@@ -199,7 +214,9 @@ class XrefTable:
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):
@@ -207,27 +224,27 @@ class PdfName:
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:
@@ -237,19 +254,19 @@ class PdfName:
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):
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:
@@ -276,7 +293,7 @@ class PdfDict(collections.UserDict):
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:
@@ -291,35 +308,35 @@ class PdfDict(collections.UserDict):
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:
msg = 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:
@@ -354,12 +371,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:
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
@@ -368,12 +392,16 @@ class PdfParser:
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
@@ -381,52 +409,53 @@ class PdfParser:
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):
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)
@@ -440,7 +469,7 @@ class PdfParser:
)
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]
@@ -469,7 +498,10 @@ class PdfParser:
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
@@ -477,7 +509,10 @@ class PdfParser:
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:
@@ -489,16 +524,20 @@ class PdfParser:
+ 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())
@@ -519,14 +558,14 @@ class PdfParser:
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"):
@@ -537,11 +576,16 @@ class PdfParser:
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:
@@ -552,12 +596,15 @@ class PdfParser:
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
@@ -565,7 +612,7 @@ class PdfParser:
# 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)
@@ -615,12 +662,13 @@ class PdfParser:
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 +676,7 @@ class PdfParser:
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 +685,14 @@ class PdfParser:
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,
@@ -662,7 +713,7 @@ class PdfParser:
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 +721,18 @@ class PdfParser:
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",
@@ -691,7 +746,7 @@ class PdfParser:
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):
@@ -753,7 +808,13 @@ class PdfParser:
)
@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 +836,16 @@ class PdfParser:
== 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 +864,53 @@ class PdfParser:
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:
msg = "bad or missing Length in stream dict (%r)" % result.get(
b"Length", None
)
raise PdfFormatError(msg) 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()
@@ -872,7 +944,7 @@ class PdfParser:
if m:
return cls.get_literal_string(data, m.end())
# return None, offset # fallback (only for debugging)
msg = "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(
@@ -898,7 +970,9 @@ class PdfParser:
}
@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):
@@ -934,12 +1008,14 @@ class PdfParser:
)
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)
@@ -954,7 +1030,8 @@ class PdfParser:
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"
if not is_free:
@@ -964,13 +1041,14 @@ class PdfParser:
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,
@@ -980,14 +1058,15 @@ class PdfParser:
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)

View File

@@ -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,8 +27,8 @@ from ._binary import i16le as i16
# helpers
def _accept(prefix):
return prefix[:4] == b"\200\350\000\000"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"\200\350\000\000")
##
@@ -38,8 +39,10 @@ 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):
msg = "not a PIXAR file"
@@ -58,7 +61,7 @@ class PixarImageFile(ImageFile.ImageFile):
# 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)]
#

View File

@@ -13,7 +13,10 @@
#
# 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
@@ -35,6 +38,7 @@ MODES = {
b"P6": "RGB",
# extensions
b"P0CMYK": "CMYK",
b"Pf": "F",
# PIL extensions (for test purposes only)
b"PyP": "P",
b"PyRGBA": "RGBA",
@@ -42,8 +46,8 @@ MODES = {
}
def _accept(prefix):
return prefix[0:1] == b"P" and prefix[1] in b"0123456y"
def _accept(prefix: bytes) -> bool:
return len(prefix) >= 2 and prefix.startswith(b"P") and prefix[1] in b"0123456fy"
##
@@ -54,7 +58,9 @@ class PpmImageFile(ImageFile.ImageFile):
format = "PPM"
format_description = "Pbmplus image"
def _read_magic(self):
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):
@@ -64,7 +70,9 @@ class PpmImageFile(ImageFile.ImageFile):
magic += c
return magic
def _read_token(self):
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)
@@ -86,17 +94,20 @@ class PpmImageFile(ImageFile.ImageFile):
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)
msg_too_long = b"Token too long in file header: %s" % token
raise ValueError(msg_too_long)
return token
def _open(self):
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"
@@ -105,40 +116,44 @@ class PpmImageFile(ImageFile.ImageFile):
elif magic_number in (b"P3", b"P6"):
self.custom_mimetype = "image/x-portable-pixmap"
maxval = None
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"
for ix in range(3):
token = int(self._read_token())
if ix == 0: # token is the x size
xsize = token
elif ix == 1: # token is the y size
ysize = token
if mode == "1":
self._mode = "1"
rawmode = "1;I"
break
else:
self._mode = rawmode = mode
elif ix == 2: # token is maxval
maxval = 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"
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: str | tuple[str | int, ...]
if mode == "1":
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)
args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval)
self._size = xsize, ysize
self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)]
rawmode = "F;32F" if scale < 0 else "F;32BF"
args = (rawmode, 0, -1)
else:
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)
]
#
@@ -147,16 +162,19 @@ class PpmImageFile(ImageFile.ImageFile):
class PpmPlainDecoder(ImageFile.PyDecoder):
_pulls_fd = True
_comment_spans: bool
def _read_block(self) -> bytes:
assert self.fd is not None
def _read_block(self):
return self.fd.read(ImageFile.SAFEBLOCK)
def _find_comment_end(self, block, start=0):
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):
def _ignore_comments(self, block: bytes) -> bytes:
if self._comment_spans:
# Finish current comment
while block:
@@ -190,7 +208,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
break
return block
def _decode_bitonal(self):
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.
@@ -212,10 +230,10 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
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")
invert = bytes.maketrans(b"01", b"\xff\x00")
return data.translate(invert)
def _decode_blocks(self, maxval):
def _decode_blocks(self, maxval: int) -> bytearray:
data = bytearray()
max_len = 10
out_byte_count = 4 if self.mode == "I" else 1
@@ -223,7 +241,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
bands = Image.getmodebands(self.mode)
total_bytes = self.state.xsize * self.state.ysize * bands * out_byte_count
half_token = False
half_token = b""
while len(data) != total_bytes:
block = self._read_block() # read next block
if not block:
@@ -237,7 +255,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
if half_token:
block = half_token + block # stitch half_token to new block
half_token = False
half_token = b""
tokens = block.split()
@@ -254,16 +272,19 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
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 = f"Channel value too large for this mode: {value}"
raise ValueError(msg)
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
return data
def decode(self, buffer):
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
self._comment_spans = False
if self.mode == "1":
data = self._decode_bitonal()
@@ -279,14 +300,17 @@ class PpmPlainDecoder(ImageFile.PyDecoder):
class PpmDecoder(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
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)
while len(data) < self.state.xsize * self.state.ysize * bands * out_byte_count:
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
@@ -306,15 +330,17 @@ class PpmDecoder(ImageFile.PyDecoder):
# --------------------------------------------------------------------
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":
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:
msg = f"cannot write mode {im.mode} as PPM"
raise OSError(msg)
@@ -326,10 +352,12 @@ def _save(im, fp, filename):
fp.write(b"255\n")
else:
fp.write(b"65535\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))]
)
#
@@ -342,6 +370,6 @@ Image.register_save(PpmImageFile.format, _save)
Image.register_decoder("ppm", PpmDecoder)
Image.register_decoder("ppm_plain", PpmPlainDecoder)
Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"])
Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"])
Image.register_mime(PpmImageFile.format, "image/x-portable-anymap")

View File

@@ -15,14 +15,19 @@
#
# 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
from ._util import DeferredError
MODES = {
# (photoshop mode, bits) -> (pil mode, required channels)
@@ -42,8 +47,8 @@ MODES = {
# read PSD images
def _accept(prefix):
return prefix[:4] == b"8BPS"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"8BPS")
##
@@ -55,7 +60,7 @@ class PsdImageFile(ImageFile.ImageFile):
format_description = "Adobe Photoshop"
_close_exclusive_fp_after_loading = False
def _open(self):
def _open(self) -> None:
read = self.fp.read
#
@@ -116,18 +121,17 @@ class PsdImageFile(ImageFile.ImageFile):
#
# 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
@@ -139,32 +143,55 @@ class PsdImageFile(ImageFile.ImageFile):
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:
if isinstance(self._fp, DeferredError):
raise self._fp.ex
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
if isinstance(self._fp, DeferredError):
raise self._fp.ex
# seek to given layer (1..max)
try:
name, mode, bbox, tile = self.layers[layer - 1]
self._mode = mode
self.tile = tile
self.frame = layer
self.fp = self._fp
return name, bbox
except IndexError as e:
msg = "no such layer"
raise EOFError(msg) from e
_, mode, _, tile = self.layers[layer - 1]
self._mode = mode
self.tile = tile
self.frame = layer
self.fp = self._fp
def tell(self):
def tell(self) -> int:
# return layer number (0=image, 1..max=layers)
return self.frame
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))
@@ -176,39 +203,41 @@ def _layerinfo(fp, ct_bytes):
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
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 _ 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)
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
@@ -235,19 +264,22 @@ def _layerinfo(fp, ct_bytes):
layers.append((name, mode, (x0, y0, x1, y1)))
# get tiles
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
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))
@@ -260,26 +292,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
@@ -289,7 +319,7 @@ def _maketile(file, mode, bbox, channels):
if offset & 1:
read(1) # padding
return tile
return tiles
# --------------------------------------------------------------------

View File

@@ -1,363 +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.c
# * 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
from ._deprecate import deprecate
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 DeferredError
FFI = ffi = DeferredError(ex)
logger = logging.getLogger(__name__)
class PyAccess:
def __init__(self, img, readonly=False):
deprecate("PyAccess", 11)
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 in ("P", "PA"):
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:
msg = "Attempt to putpixel a read only image"
raise ValueError(msg)
(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 in ("P", "PA")
and isinstance(color, (list, tuple))
and len(color) in [3, 4]
):
# RGB or RGBA value for a P or PA image
if self._im.mode == "PA":
alpha = color[3] if len(color) == 4 else 255
color = color[:3]
color = self._palette.getcolor(color, self._img)
if self._im.mode == "PA":
color = (color, alpha)
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):
msg = "pixel location out of range"
raise ValueError(msg)
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,
"I;16N": _PyAccessI16_N,
"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)

View File

@@ -5,101 +5,230 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import os
from typing import IO
from . import Image, ImageFile
from ._binary import i32be as i32
from ._binary import o8
from ._binary import o32be as o32
def _accept(prefix):
return prefix[:4] == b"qoif"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"qoif")
class QoiImageFile(ImageFile.ImageFile):
format = "QOI"
format_description = "Quite OK Image"
def _open(self):
def _open(self) -> None:
if not _accept(self.fp.read(4)):
msg = "not a QOI file"
raise SyntaxError(msg)
self._size = tuple(i32(self.fp.read(4)) for i in range(2))
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 = [("qoi", (0, 0) + self._size, self.fp.tell(), None)]
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):
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):
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
self._previously_seen_pixels = {}
self._previous_pixel = None
self._add_to_previous_pixels(b"".join(o8(i) for i in (0, 0, 0, 255)))
self._previous_pixel = bytearray((0, 0, 0, 255))
data = bytearray()
bands = Image.getmodebands(self.mode)
while len(data) < self.state.xsize * self.state.ysize * bands:
dest_length = self.state.xsize * self.state.ysize * bands
while len(data) < dest_length:
byte = self.fd.read(1)[0]
if byte == 0b11111110: # QOI_OP_RGB
value = self.fd.read(3) + self._previous_pixel[3:]
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, (0, 0, 0, 0))
elif op == 1: # QOI_OP_DIFF
value = (
(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,
value = self._previously_seen_pixels.get(
op_index, bytearray((0, 0, 0, 0))
)
value += (self._previous_pixel[3],)
elif op == 2: # QOI_OP_LUMA
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 = tuple(
(self._previous_pixel[i] + diff_green + diff) % 256
for i, diff in enumerate((diff_red, 0, diff_blue))
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: # QOI_OP_RUN
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
value = b"".join(o8(i) for i in value)
self._add_to_previous_pixels(value)
if bands == 3:
value = value[:3]
data += value
self.set_as_raw(bytes(data))
self.set_as_raw(data)
return -1, 0
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode == "RGB":
channels = 3
elif im.mode == "RGBA":
channels = 4
else:
msg = "Unsupported QOI image mode"
raise ValueError(msg)
colorspace = 0 if im.encoderinfo.get("colorspace") == "sRGB" else 1
fp.write(b"qoif")
fp.write(o32(im.size[0]))
fp.write(o32(im.size[1]))
fp.write(o8(channels))
fp.write(o8(colorspace))
ImageFile._save(im, fp, [ImageFile._Tile("qoi", (0, 0) + im.size)])
class QoiEncoder(ImageFile.PyEncoder):
_pushes_fd = True
_previous_pixel: tuple[int, int, int, int] | None = None
_previously_seen_pixels: dict[int, tuple[int, int, int, int]] = {}
_run = 0
def _write_run(self) -> bytes:
data = o8(0b11000000 | (self._run - 1)) # QOI_OP_RUN
self._run = 0
return data
def _delta(self, left: int, right: int) -> int:
result = (left - right) & 255
if result >= 128:
result -= 256
return result
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
assert self.im is not None
self._previously_seen_pixels = {0: (0, 0, 0, 0)}
self._previous_pixel = (0, 0, 0, 255)
data = bytearray()
w, h = self.im.size
bands = Image.getmodebands(self.mode)
for y in range(h):
for x in range(w):
pixel = self.im.getpixel((x, y))
if bands == 3:
pixel = (*pixel, 255)
if pixel == self._previous_pixel:
self._run += 1
if self._run == 62:
data += self._write_run()
else:
if self._run:
data += self._write_run()
r, g, b, a = pixel
hash_value = (r * 3 + g * 5 + b * 7 + a * 11) % 64
if self._previously_seen_pixels.get(hash_value) == pixel:
data += o8(hash_value) # QOI_OP_INDEX
elif self._previous_pixel:
self._previously_seen_pixels[hash_value] = pixel
prev_r, prev_g, prev_b, prev_a = self._previous_pixel
if prev_a == a:
delta_r = self._delta(r, prev_r)
delta_g = self._delta(g, prev_g)
delta_b = self._delta(b, prev_b)
if (
-2 <= delta_r < 2
and -2 <= delta_g < 2
and -2 <= delta_b < 2
):
data += o8(
0b01000000
| (delta_r + 2) << 4
| (delta_g + 2) << 2
| (delta_b + 2)
) # QOI_OP_DIFF
else:
delta_gr = self._delta(delta_r, delta_g)
delta_gb = self._delta(delta_b, delta_g)
if (
-8 <= delta_gr < 8
and -32 <= delta_g < 32
and -8 <= delta_gb < 8
):
data += o8(
0b10000000 | (delta_g + 32)
) # QOI_OP_LUMA
data += o8((delta_gr + 8) << 4 | (delta_gb + 8))
else:
data += o8(0b11111110) # QOI_OP_RGB
data += bytes(pixel[:3])
else:
data += o8(0b11111111) # QOI_OP_RGBA
data += bytes(pixel)
self._previous_pixel = pixel
if self._run:
data += self._write_run()
data += bytes((0, 0, 0, 0, 0, 0, 0, 1)) # padding
return len(data), 0, data
Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
Image.register_decoder("qoi", QoiDecoder)
Image.register_extension(QoiImageFile.format, ".qoi")
Image.register_save(QoiImageFile.format, _save)
Image.register_encoder("qoi", QoiEncoder)

View File

@@ -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
@@ -52,8 +53,10 @@ 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)
@@ -79,17 +82,10 @@ class SgiImageFile(ImageFile.ImageFile):
# zsize : channels count
zsize = i16(s, 10)
# layout
layout = bpc, dimension, zsize
# determine mode from bits/zsize
rawmode = ""
try:
rawmode = MODES[layout]
rawmode = MODES[(bpc, dimension, zsize)]
except KeyError:
pass
if rawmode == "":
msg = "Unsupported SGI image mode"
raise ValueError(msg)
@@ -106,24 +102,33 @@ class SgiImageFile(ImageFile.ImageFile):
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":
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)
@@ -144,24 +149,15 @@ def _save(im, fp, filename):
# Run-Length Encoding Compression - Unsupported at this time
rle = 0
# Number of dimensions (x,y,z)
dim = 3
# X Dimension = width / Y Dimension = height
x, y = im.size
if im.mode == "L" and y == 1:
dim = 1
elif im.mode == "L":
dim = 2
# Z Dimension: Number of channels
z = len(im.mode)
if dim == 1 or dim == 2:
z = 1
# assert we've got the right number of bands.
if len(im.getbands()) != z:
msg = f"incorrect number of bands in SGI write: {z} vs {len(im.getbands())}"
raise ValueError(msg)
# Number of dimensions (x,y,z)
if im.mode == "L":
dimension = 1 if y == 1 else 2
else:
dimension = 3
# Minimum Byte value
pinmin = 0
@@ -169,13 +165,14 @@ def _save(im, fp, filename):
pinmax = 255
# Image name (79 characters max, truncated below in write)
img_name = os.path.splitext(os.path.basename(filename))[0]
img_name = img_name.encode("ascii", "ignore")
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", magic_number))
fp.write(o8(rle))
fp.write(o8(bpc))
fp.write(struct.pack(">H", dim))
fp.write(struct.pack(">H", dimension))
fp.write(struct.pack(">H", x))
fp.write(struct.pack(">H", y))
fp.write(struct.pack(">H", z))
@@ -201,7 +198,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)

View File

@@ -32,14 +32,20 @@
# 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, Any, cast
from . import Image, ImageFile
from ._util import DeferredError
TYPE_CHECKING = False
def isInt(f):
def isInt(f: Any) -> int:
try:
i = int(f)
if f - i == 0:
@@ -59,7 +65,7 @@ iforms = [1, 3, -11, -12, -21, -22]
# 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 +85,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
@@ -95,7 +101,7 @@ class SpiderImageFile(ImageFile.ImageFile):
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)
@@ -151,46 +157,53 @@ class SpiderImageFile(ImageFile.ImageFile):
self.rawmode = "F;32F"
self._mode = "F"
self.tile = [("raw", (0, 0) + self.size, offset, (self.rawmode, 0, 1))]
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:
msg = "attempt to seek in a non-stack file"
raise EOFError(msg)
if not self._seek_check(frame):
return
if isinstance(self._fp, DeferredError):
raise self._fp.ex
self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes)
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):
def tkPhotoImage(self) -> ImageTk.PhotoImage:
from . import ImageTk
return ImageTk.PhotoImage(self.convert2byte(), palette=256)
@@ -201,33 +214,34 @@ class SpiderImageFile(ImageFile.ImageFile):
# 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)
@@ -238,9 +252,7 @@ def makeSpiderHeader(im):
if nvalues < 23:
return []
hdr = []
for i in range(nvalues):
hdr.append(0.0)
hdr = [0.0] * nvalues
# NB these are Fortran indices
hdr[1] = 1.0 # nslice (=1 for an image)
@@ -259,8 +271,8 @@ def makeSpiderHeader(im):
return [struct.pack("f", v) for v in hdr]
def _save(im, fp, filename):
if im.mode[0] != "F":
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode != "F":
im = im.convert("F")
hdr = makeSpiderHeader(im)
@@ -272,12 +284,13 @@ def _save(im, fp, filename):
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)
@@ -299,10 +312,10 @@ if __name__ == "__main__":
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())

View File

@@ -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
@@ -33,7 +33,7 @@ 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:
@@ -49,6 +49,8 @@ class SunImageFile(ImageFile.ImageFile):
# 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):
@@ -122,9 +124,13 @@ class SunImageFile(ImageFile.ImageFile):
# (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:
msg = "Unsupported Sun Raster file type"
raise SyntaxError(msg)

View File

@@ -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.
@@ -34,12 +35,16 @@ class TarIO(ContainerIO.ContainerIO):
while True:
s = self.fh.read(512)
if len(s) != 512:
self.fh.close()
msg = "unexpected end of tar file"
raise OSError(msg)
name = s[:100].decode("utf-8")
i = name.find("\0")
if i == 0:
self.fh.close()
msg = "cannot find subfile"
raise OSError(msg)
if i > 0:
@@ -54,13 +59,3 @@ class TarIO(ContainerIO.ContainerIO):
# 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()

View File

@@ -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 @@ MODES = {
(3, 1): "1",
(3, 8): "L",
(3, 16): "LA",
(2, 16): "BGR;5",
(2, 16): "BGRA;15Z",
(2, 24): "BGR",
(2, 32): "BGRA",
}
@@ -49,8 +50,10 @@ 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]
@@ -82,11 +85,9 @@ class TgaImageFile(ImageFile.ImageFile):
elif depth == 16:
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:
msg = "unknown TGA mode"
raise SyntaxError(msg)
@@ -115,16 +116,20 @@ class TgaImageFile(ImageFile.ImageFile):
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:
@@ -132,7 +137,7 @@ class TgaImageFile(ImageFile.ImageFile):
if imagetype & 8:
# compressed
self.tile = [
(
ImageFile._Tile(
"tga_rle",
(0, 0) + self.size,
self.fp.tell(),
@@ -141,7 +146,7 @@ class TgaImageFile(ImageFile.ImageFile):
]
else:
self.tile = [
(
ImageFile._Tile(
"raw",
(0, 0) + self.size,
self.fp.tell(),
@@ -151,7 +156,7 @@ class TgaImageFile(ImageFile.ImageFile):
except KeyError:
pass # cannot decode
def load_end(self):
def load_end(self) -> None:
if self._flip_horizontally:
self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
@@ -171,7 +176,7 @@ SAVE = {
}
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:
@@ -231,11 +236,15 @@ def _save(im, fp, filename):
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

View File

@@ -16,24 +16,40 @@
# 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
:param group: Which :py:data:`~PIL.TiffTags.TAGS_V2_GROUPS` to look in
@@ -56,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
@@ -80,7 +96,7 @@ 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),
@@ -187,6 +203,11 @@ TAGS_V2 = {
531: ("YCbCrPositioning", SHORT, 1),
532: ("ReferenceBlackWhite", RATIONAL, 6),
700: ("XMP", BYTE, 0),
# Four private SGI tags
32995: ("Matteing", SHORT, 1),
32996: ("DataType", SHORT, 0),
32997: ("ImageDepth", LONG, 1),
32998: ("TileDepth", LONG, 1),
33432: ("Copyright", ASCII, 1),
33723: ("IptcNaaInfo", UNDEFINED, 1),
34377: ("PhotoshopInfo", BYTE, 0),
@@ -224,7 +245,7 @@ TAGS_V2 = {
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),
@@ -272,7 +293,7 @@ TAGS_V2_GROUPS = {
# 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
@@ -416,9 +437,12 @@ TAGS = {
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:
@@ -427,32 +451,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

View File

@@ -22,16 +22,20 @@ and has been tested with a few sample files found using google.
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):
def _open(self) -> None:
self._mode = "P"
# read header fields
@@ -45,19 +49,18 @@ class WalImageFile(ImageFile.ImageFile):
# strings are null-terminated
self.info["name"] = header[:32].split(b"\0", 1)[0]
next_name = header[56 : 56 + 32].split(b"\0", 1)[0]
if next_name:
if next_name := header[56 : 56 + 32].split(b"\0", 1)[0]:
self.info["next_name"] = next_name
def load(self):
if not self.im:
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.

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from io import BytesIO
from . import Image, ImageFile
@@ -9,10 +11,9 @@ try:
except ImportError:
SUPPORTED = False
_VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True}
_VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True}
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import IO, Any
_VP8_MODES_BY_IDENTIFIER = {
b"VP8 ": "RGB",
@@ -21,8 +22,8 @@ _VP8_MODES_BY_IDENTIFIER = {
}
def _accept(prefix):
is_riff_file_format = prefix[:4] == b"RIFF"
def _accept(prefix: bytes) -> bool | str:
is_riff_file_format = prefix.startswith(b"RIFF")
is_webp_file = prefix[8:12] == b"WEBP"
is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER
@@ -32,6 +33,7 @@ def _accept(prefix):
"image file could not be identified because WEBP support not installed"
)
return True
return False
class WebPImageFile(ImageFile.ImageFile):
@@ -40,30 +42,13 @@ class WebPImageFile(ImageFile.ImageFile):
__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())
# Get info from decoder
width, height, loop_count, bgcolor, frame_count, mode = self._decoder.get_info()
self._size = width, height
self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info()
self.info["loop"] = loop_count
bg_a, bg_r, bg_g, bg_b = (
(bgcolor >> 24) & 0xFF,
@@ -76,7 +61,6 @@ class WebPImageFile(ImageFile.ImageFile):
self.is_animated = self.n_frames > 1
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")
@@ -92,35 +76,26 @@ class WebPImageFile(ImageFile.ImageFile):
# 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 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["xmp"]) if "xmp" in self.info else {}
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
@@ -141,7 +116,7 @@ class WebPImageFile(ImageFile.ImageFile):
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:
@@ -149,33 +124,39 @@ class WebPImageFile(ImageFile.ImageFile):
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", []))
@@ -188,7 +169,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:
@@ -212,6 +193,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", "")
@@ -242,8 +224,7 @@ def _save_all(im, fp, filename):
# Setup the WebP animation encoder
enc = _webp.WebPAnimEncoder(
im.size[0],
im.size[1],
im.size,
background,
loop,
minimize_size,
@@ -259,38 +240,21 @@ def _save_all(im, fp, filename):
cur_idx = im.tell()
try:
for ims in [im] + append_images:
# Get # of frames in this image
# Get number of frames in this image
nfr = getattr(ims, "n_frames", 1)
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),
frame.getim(),
round(timestamp),
frame.size[0],
frame.size[1],
rawmode,
lossless,
quality,
alpha_quality,
method,
)
@@ -305,7 +269,7 @@ def _save_all(im, fp, filename):
im.seek(cur_idx)
# Force encoder to flush frames
enc.add(None, round(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)
@@ -316,9 +280,10 @@ def _save_all(im, fp, filename):
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", b"")
if isinstance(exif, Image.Exif):
@@ -329,16 +294,13 @@ def _save(im, fp, filename):
method = im.encoderinfo.get("method", 4)
exact = 1 if im.encoderinfo.get("exact") else 0
if im.mode not in _VALID_WEBP_LEGACY_MODES:
im = im.convert("RGBA" if im.has_transparency_data 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,
@@ -355,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")

View File

@@ -18,6 +18,9 @@
# 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
@@ -27,7 +30,7 @@ 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.
@@ -40,12 +43,12 @@ def register_handler(handler):
if hasattr(Image.core, "drawwmf"):
# install default handler (windows only)
class WmfHandler:
def open(self, im):
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",
@@ -64,10 +67,8 @@ if hasattr(Image.core, "drawwmf"):
# Read WMF file
def _accept(prefix):
return (
prefix[:6] == b"\xd7\xcd\xc6\x9a\x00\x00" or prefix[:4] == b"\x01\x00\x00\x00"
)
def _accept(prefix: bytes) -> bool:
return prefix.startswith((b"\xd7\xcd\xc6\x9a\x00\x00", b"\x01\x00\x00\x00"))
##
@@ -78,17 +79,19 @@ class WmfStubImageFile(ImageFile.StubImageFile):
format = "WMF"
format_description = "Windows Metafile"
def _open(self):
self._inch = None
def _open(self) -> None:
# check placeable header
s = self.fp.read(44)
# check placable header
s = self.fp.read(80)
if s[:6] == b"\xd7\xcd\xc6\x9a\x00\x00":
if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"):
# placeable windows metafile
# get units per inch
self._inch = word(s, 14)
inch = word(s, 14)
if inch == 0:
msg = "Invalid inch"
raise ValueError(msg)
self._inch: tuple[float, float] = inch, inch
# get bounding box
x0 = short(s, 6)
@@ -99,8 +102,8 @@ class WmfStubImageFile(ImageFile.StubImageFile):
# normalize size to 72 dots per inch
self.info["dpi"] = 72
size = (
(x1 - x0) * self.info["dpi"] // self._inch,
(y1 - y0) * self.info["dpi"] // self._inch,
(x1 - x0) * self.info["dpi"] // inch,
(y1 - y0) * self.info["dpi"] // inch,
)
self.info["wmf_bbox"] = x0, y0, x1, y1
@@ -110,7 +113,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
msg = "Unsupported WMF file format"
raise SyntaxError(msg)
elif s[:4] == b"\x01\x00\x00\x00" and s[40:44] == b" EMF":
elif s.startswith(b"\x01\x00\x00\x00") and s[40:44] == b" EMF":
# enhanced metafile
# get bounding box
@@ -125,7 +128,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
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
@@ -134,6 +137,7 @@ class WmfStubImageFile(ImageFile.StubImageFile):
self.info["dpi"] = xdpi
else:
self.info["dpi"] = xdpi, ydpi
self._inch = xdpi, ydpi
else:
msg = "Unsupported file format"
@@ -146,21 +150,25 @@ class WmfStubImageFile(ImageFile.StubImageFile):
if loader:
loader.open(self)
def _load(self):
def _load(self) -> ImageFile.StubHandler | None:
return _handler
def load(self, dpi=None):
if dpi is not None and self._inch is not None:
def load(
self, dpi: float | tuple[float, float] | None = None
) -> Image.core.PixelAccess | None:
if dpi is not None:
self.info["dpi"] = dpi
x0, y0, x1, y1 = self.info["wmf_bbox"]
if not isinstance(dpi, tuple):
dpi = dpi, dpi
self._size = (
(x1 - x0) * self.info["dpi"] // self._inch,
(y1 - y0) * self.info["dpi"] // self._inch,
int((x1 - x0) * dpi[0] / self._inch[0]),
int((y1 - y0) * dpi[1] / self._inch[1]),
)
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"):
msg = "WMF save handler not installed"
raise OSError(msg)

View File

@@ -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,8 +33,8 @@ for r in range(8):
)
def _accept(prefix):
return prefix[:6] == _MAGIC
def _accept(prefix: bytes) -> bool:
return prefix.startswith(_MAGIC)
##
@@ -44,8 +45,10 @@ 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)):
msg = "not an XV thumbnail file"
raise SyntaxError(msg)
@@ -70,7 +73,9 @@ class XVThumbImageFile(ImageFile.ImageFile):
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)
]
# --------------------------------------------------------------------

View File

@@ -18,8 +18,10 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
from typing import IO
from . import Image, ImageFile
@@ -35,8 +37,8 @@ xbm_head = re.compile(
)
def _accept(prefix):
return prefix.lstrip()[:7] == b"#define"
def _accept(prefix: bytes) -> bool:
return prefix.lstrip().startswith(b"#define")
##
@@ -47,7 +49,9 @@ 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 not m:
@@ -63,10 +67,10 @@ class XbmImageFile(ImageFile.ImageFile):
self._mode = "1"
self._size = xsize, ysize
self.tile = [("xbm", (0, 0) + self.size, m.end(), None)]
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":
msg = f"cannot write mode {im.mode} as XBM"
raise OSError(msg)
@@ -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")

View File

@@ -13,7 +13,7 @@
#
# See the README file for information on usage and redistribution.
#
from __future__ import annotations
import re
@@ -24,8 +24,8 @@ from ._binary import o8
xpm_head = re.compile(b'"([0-9]*) ([0-9]*) ([0-9]*) ([0-9]*)')
def _accept(prefix):
return prefix[:9] == b"/* XPM */"
def _accept(prefix: bytes) -> bool:
return prefix.startswith(b"/* XPM */")
##
@@ -36,44 +36,37 @@ class XpmImageFile(ImageFile.ImageFile):
format = "XPM"
format_description = "X11 Pixel Map"
def _open(self):
def _open(self) -> None:
assert self.fp is not None
if not _accept(self.fp.read(9)):
msg = "not an XPM file"
raise SyntaxError(msg)
# skip forward to next string
while True:
s = self.fp.readline()
if not s:
line = self.fp.readline()
if not line:
msg = "broken XPM file"
raise SyntaxError(msg)
m = xpm_head.match(s)
m = xpm_head.match(line)
if m:
break
self._size = int(m.group(1)), int(m.group(2))
pal = int(m.group(3))
palette_length = int(m.group(3))
bpp = int(m.group(4))
if pal > 256 or bpp != 1:
msg = "cannot read this XPM file"
raise ValueError(msg)
#
# load palette description
palette = [b"\0\0\0"] * 256
palette = {}
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]
for _ in range(palette_length):
line = self.fp.readline().rstrip()
c = s[1]
s = s[2:-2].split()
c = line[1 : bpp + 1]
s = line[bpp + 1 : -2].split()
for i in range(0, len(s), 2):
if s[i] == b"c":
@@ -81,11 +74,12 @@ class XpmImageFile(ImageFile.ImageFile):
rgb = s[i + 1]
if rgb == b"None":
self.info["transparency"] = c
elif rgb[:1] == b"#":
# FIXME: handle colour names (see ImagePalette.py)
rgb = int(rgb[1:], 16)
elif rgb.startswith(b"#"):
rgb_int = int(rgb[1:], 16)
palette[c] = (
o8((rgb >> 16) & 255) + o8((rgb >> 8) & 255) + o8(rgb & 255)
o8((rgb_int >> 16) & 255)
+ o8((rgb_int >> 8) & 255)
+ o8(rgb_int & 255)
)
else:
# unknown colour
@@ -98,30 +92,65 @@ class XpmImageFile(ImageFile.ImageFile):
msg = "cannot read this XPM file"
raise ValueError(msg)
self._mode = "P"
self.palette = ImagePalette.raw("RGB", b"".join(palette))
args: tuple[int, dict[bytes, bytes] | tuple[bytes, ...]]
if palette_length > 256:
self._mode = "RGB"
args = (bpp, palette)
else:
self._mode = "P"
self.palette = ImagePalette.raw("RGB", b"".join(palette.values()))
args = (bpp, tuple(palette.keys()))
self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))]
self.tile = [ImageFile._Tile("xpm", (0, 0) + self.size, self.fp.tell(), args)]
def load_read(self, bytes):
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)
assert self.fp is not None
s = [self.fp.readline()[1 : xsize + 1].ljust(xsize) for i in range(ysize)]
return b"".join(s)
class XpmDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
assert self.fd is not None
data = bytearray()
bpp, palette = self.args
dest_length = self.state.xsize * self.state.ysize
if self.mode == "RGB":
dest_length *= 3
pixel_header = False
while len(data) < dest_length:
line = self.fd.readline()
if not line:
break
if line.rstrip() == b"/* pixels */" and not pixel_header:
pixel_header = True
continue
line = b'"'.join(line.split(b'"')[1:-1])
for i in range(0, len(line), bpp):
key = line[i : i + bpp]
if self.mode == "RGB":
data += palette[key]
else:
data += o8(palette.index(key))
self.set_as_raw(bytes(data))
return -1, 0
#
# Registry
Image.register_open(XpmImageFile.format, XpmImageFile, _accept)
Image.register_decoder("xpm", XpmDecoder)
Image.register_extension(XpmImageFile.format, ".xpm")

View File

@@ -1,6 +1,6 @@
"""Pillow (Fork of the Python Imaging Library)
Pillow is the friendly PIL fork by Jeffrey A. Clark (Alex) 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.
@@ -13,6 +13,8 @@ Use PIL.__version__ for this Pillow version.
;-)
"""
from __future__ import annotations
from . import _version
# VERSION was removed in Pillow 6.0.0.
@@ -23,6 +25,7 @@ del _version
_plugins = [
"AvifImagePlugin",
"BlpImagePlugin",
"BmpImagePlugin",
"BufrStubImagePlugin",

View File

@@ -1,3 +1,7 @@
from __future__ import annotations
import sys
from .features import pilinfo
pilinfo()
pilinfo(supported_formats="--report" not in sys.argv)

Some files were not shown because too many files have changed in this diff Show More