Merge branch 'main' into closed

This commit is contained in:
Andrew Murray 2023-03-13 08:00:11 +11:00 committed by GitHub
commit a2290358d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 317 additions and 92 deletions

View File

@ -5,6 +5,18 @@ Changelog (Pillow)
9.5.0 (unreleased)
------------------
- Close OleFileIO instance when closing or exiting FPX or MIC #7005
[radarhere]
- Added __int__ to IFDRational for Python >= 3.11 #6998
[radarhere]
- Added memoryview support to Dib.frombytes() #6988
[radarhere, nulano]
- Close file pointer copy in the libtiff encoder if still open #6986
[fcarron, radarhere]
- Raise an error if ImageDraw co-ordinates are incorrectly ordered #6978
[radarhere]

View File

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
HAS_UPLOADER = False
if os.environ.get("SHOW_ERRORS", None):
if os.environ.get("SHOW_ERRORS"):
# local img.show for errors.
HAS_UPLOADER = True
@ -271,7 +271,7 @@ def netpbm_available():
def magick_command():
if sys.platform == "win32":
magickhome = os.environ.get("MAGICK_HOME", "")
magickhome = os.environ.get("MAGICK_HOME")
if magickhome:
imagemagick = [os.path.join(magickhome, "convert.exe")]
graphicsmagick = [os.path.join(magickhome, "gm.exe"), "convert"]

BIN
Tests/images/hopper.qoi Normal file

Binary file not shown.

BIN
Tests/images/pil123rgba.qoi Normal file

Binary file not shown.

View File

@ -56,6 +56,7 @@ def test_handler(tmp_path):
def load(self, im):
self.loaded = True
im.fp.close()
return Image.new("RGB", (1, 1))
def save(self, im, fp, filename):

View File

@ -60,6 +60,7 @@ def test_stub_deprecated():
def load(self, im):
self.loaded = True
im.fp.close()
return Image.new("RGB", (1, 1))
handler = Handler()

View File

@ -18,6 +18,16 @@ def test_sanity():
assert_image_equal_tofile(im, "Tests/images/input_bw_one_band.png")
def test_close():
with Image.open("Tests/images/input_bw_one_band.fpx") as im:
pass
assert im.ole.fp.closed
im = Image.open("Tests/images/input_bw_one_band.fpx")
im.close()
assert im.ole.fp.closed
def test_invalid_file():
# Test an invalid OLE file
invalid_file = "Tests/images/flower.jpg"

View File

@ -56,6 +56,7 @@ def test_handler(tmp_path):
def load(self, im):
self.loaded = True
im.fp.close()
return Image.new("RGB", (1, 1))
def save(self, im, fp, filename):

View File

@ -57,6 +57,7 @@ def test_handler(tmp_path):
def load(self, im):
self.loaded = True
im.fp.close()
return Image.new("RGB", (1, 1))
def save(self, im, fp, filename):

View File

@ -984,6 +984,36 @@ class TestFileLibTiff(LibTiffTestCase):
) as im:
assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png")
@pytest.mark.parametrize(
"file_name, mode, size, tile",
[
(
"tiff_wrong_bits_per_sample.tiff",
"RGBA",
(52, 53),
[("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))],
),
(
"tiff_wrong_bits_per_sample_2.tiff",
"RGB",
(16, 16),
[("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))],
),
(
"tiff_wrong_bits_per_sample_3.tiff",
"RGBA",
(512, 256),
[("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))],
),
],
)
def test_wrong_bits_per_sample(self, file_name, mode, size, tile):
with Image.open("Tests/images/" + file_name) as im:
assert im.mode == mode
assert im.size == size
assert im.tile == tile
im.load()
def test_no_rows_per_strip(self):
# This image does not have a RowsPerStrip TIFF tag
infile = "Tests/images/no_rows_per_strip.tif"
@ -1071,3 +1101,21 @@ class TestFileLibTiff(LibTiffTestCase):
out = str(tmp_path / "temp.tif")
for _ in range(10000):
im.save(out, compression="jpeg")
@pytest.mark.parametrize(
"path, sizes",
(
("Tests/images/hopper.tif", ()),
("Tests/images/child_ifd.tiff", (16, 8)),
("Tests/images/child_ifd_jpeg.tiff", (20,)),
),
)
def test_get_child_images(self, path, sizes):
with Image.open(path) as im:
ims = im.get_child_images()
assert len(ims) == len(sizes)
for i, im in enumerate(ims):
w = sizes[i]
expected = Image.new("RGB", (w, w), "#f00")
assert_image_similar(im, expected, 1)

View File

@ -51,6 +51,16 @@ def test_seek():
assert im.tell() == 0
def test_close():
with Image.open(TEST_FILE) as im:
pass
assert im.ole.fp.closed
im = Image.open(TEST_FILE)
im.close()
assert im.ole.fp.closed
def test_invalid_file():
# Test an invalid OLE file
invalid_file = "Tests/images/flower.jpg"

View File

@ -8,7 +8,7 @@ import pytest
from PIL import Image, PdfParser, features
from .helper import hopper, mark_if_feature_version
from .helper import hopper, mark_if_feature_version, skip_unless_feature
def helper_save_as_pdf(tmp_path, mode, **kwargs):
@ -42,6 +42,11 @@ def test_save(tmp_path, mode):
helper_save_as_pdf(tmp_path, mode)
@skip_unless_feature("jpg_2000")
def test_save_rgba(tmp_path):
helper_save_as_pdf(tmp_path, "RGBA")
def test_monochrome(tmp_path):
# Arrange
mode = "1"

28
Tests/test_file_qoi.py Normal file
View File

@ -0,0 +1,28 @@
import pytest
from PIL import Image, QoiImagePlugin
from .helper import assert_image_equal_tofile, assert_image_similar_tofile
def test_sanity():
with Image.open("Tests/images/hopper.qoi") as im:
assert im.mode == "RGB"
assert im.size == (128, 128)
assert im.format == "QOI"
assert_image_equal_tofile(im, "Tests/images/hopper.png")
with Image.open("Tests/images/pil123rgba.qoi") as im:
assert im.mode == "RGBA"
assert im.size == (162, 150)
assert im.format == "QOI"
assert_image_similar_tofile(im, "Tests/images/pil123rgba.png", 0.03)
def test_invalid_file():
invalid_file = "Tests/images/flower.jpg"
with pytest.raises(SyntaxError):
QoiImagePlugin.QoiImageFile(invalid_file)

View File

@ -84,24 +84,6 @@ class TestFileTiff:
with Image.open("Tests/images/multipage.tiff") as im:
im.load()
@pytest.mark.parametrize(
"path, sizes",
(
("Tests/images/hopper.tif", ()),
("Tests/images/child_ifd.tiff", (16, 8)),
("Tests/images/child_ifd_jpeg.tiff", (20,)),
),
)
def test_get_child_images(self, path, sizes):
with Image.open(path) as im:
ims = im.get_child_images()
assert len(ims) == len(sizes)
for i, im in enumerate(ims):
w = sizes[i]
expected = Image.new("RGB", (w, w), "#f00")
assert_image_similar(im, expected, 1)
def test_mac_tiff(self):
# Read RGBa images from macOS [@PIL136]
@ -118,36 +100,6 @@ class TestFileTiff:
with Image.open("Tests/images/hopper_bigtiff.tif") as im:
assert_image_equal_tofile(im, "Tests/images/hopper.tif")
@pytest.mark.parametrize(
"file_name,mode,size,tile",
[
(
"tiff_wrong_bits_per_sample.tiff",
"RGBA",
(52, 53),
[("raw", (0, 0, 52, 53), 160, ("RGBA", 0, 1))],
),
(
"tiff_wrong_bits_per_sample_2.tiff",
"RGB",
(16, 16),
[("raw", (0, 0, 16, 16), 8, ("RGB", 0, 1))],
),
(
"tiff_wrong_bits_per_sample_3.tiff",
"RGBA",
(512, 256),
[("libtiff", (0, 0, 512, 256), 0, ("RGBA", "tiff_lzw", False, 48782))],
),
],
)
def test_wrong_bits_per_sample(self, file_name, mode, size, tile):
with Image.open("Tests/images/" + file_name) as im:
assert im.mode == mode
assert im.size == size
assert im.tile == tile
im.load()
def test_set_legacy_api(self):
ifd = TiffImagePlugin.ImageFileDirectory_v2()
with pytest.raises(Exception) as e:

View File

@ -254,17 +254,6 @@ def test_p2pa_palette():
assert im_pa.getpalette() == im.getpalette()
@pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX"))
def test_rgb_lab(mode):
im = Image.new(mode, (1, 1))
converted_im = im.convert("LAB")
assert converted_im.getpixel((0, 0)) == (0, 128, 128)
im = Image.new("LAB", (1, 1), (255, 0, 0))
converted_im = im.convert(mode)
assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255)
def test_matrix_illegal_conversion():
# Arrange
im = hopper("CMYK")

View File

@ -625,3 +625,14 @@ def test_constants_deprecation():
for name in enum.__members__:
with pytest.warns(DeprecationWarning):
assert getattr(ImageCms, prefix + name) == enum[name]
@pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX"))
def test_rgb_lab(mode):
im = Image.new(mode, (1, 1))
converted_im = im.convert("LAB")
assert converted_im.getpixel((0, 0)) == (0, 128, 128)
im = Image.new("LAB", (1, 1), (255, 0, 0))
converted_im = im.convert(mode)
assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255)

View File

@ -55,8 +55,8 @@ def test_show_without_viewers():
viewers = ImageShow._viewers
ImageShow._viewers = []
im = hopper()
assert not ImageShow.show(im)
with hopper() as im:
assert not ImageShow.show(im)
ImageShow._viewers = viewers

View File

@ -100,8 +100,11 @@ class TestImageWinDib:
# Act
# Make one the same as the using tobytes()/frombytes()
test_buffer = dib1.tobytes()
dib2.frombytes(test_buffer)
for datatype in ("bytes", "memoryview"):
if datatype == "memoryview":
test_buffer = memoryview(test_buffer)
dib2.frombytes(test_buffer)
# Assert
# Confirm they're the same
assert dib1.tobytes() == dib2.tobytes()
# Assert
# Confirm they're the same
assert dib1.tobytes() == dib2.tobytes()

View File

@ -1,7 +1,7 @@
#!/bin/bash
# install libimagequant
archive=libimagequant-4.1.0
archive=libimagequant-4.1.1
./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz

View File

@ -1457,8 +1457,13 @@ PDF
^^^
Pillow can write PDF (Acrobat) images. Such images are written as binary PDF 1.4
files, using either JPEG or HEX encoding depending on the image mode (and
whether JPEG support is available or not).
files. Different encoding methods are used, depending on the image mode.
* 1 mode images are saved using TIFF encoding, or JPEG encoding if libtiff support is
unavailable
* L, RGB and CMYK mode images use JPEG encoding
* P mode images use HEX encoding
* RGBA mode images use JPEG2000 encoding
.. _pdf-saving:
@ -1544,6 +1549,13 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
.. versionadded:: 5.3.0
QOI
^^^
.. versionadded:: 9.5.0
Pillow identifies and reads images in Quite OK Image format.
XV Thumbnails
^^^^^^^^^^^^^

View File

@ -169,7 +169,7 @@ Many of Pillow's features require external libraries:
* **libimagequant** provides improved color quantization
* Pillow has been tested with libimagequant **2.6-4.1**
* Pillow has been tested with libimagequant **2.6-4.1.1**
* Libimagequant is licensed GPLv3, which is more restrictive than
the Pillow license, therefore we will not be distributing binaries
with libimagequant support enabled.

View File

@ -28,6 +28,11 @@ TODO
API Additions
=============
QOI file format
^^^^^^^^^^^^^^^
Pillow can now read images in Quite OK Image format.
Added ``closed`` property to images
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -61,7 +66,7 @@ TODO
Other Changes
=============
TODO
^^^^
Added support for saving PDFs in RGBA mode
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO
Using the JPXDecode filter, PDFs can now be saved in RGBA mode.

View File

@ -242,7 +242,9 @@ def _find_include_dir(self, dirname, include):
return subdir
def _cmd_exists(cmd):
def _cmd_exists(cmd: str) -> bool:
if "PATH" not in os.environ:
return False
return any(
os.access(os.path.join(path, cmd), os.X_OK)
for path in os.environ["PATH"].split(os.pathsep)
@ -570,9 +572,7 @@ class pil_build_ext(build_ext):
):
for dirname in _find_library_dirs_ldconfig():
_add_directory(library_dirs, dirname)
if sys.platform.startswith("linux") and os.environ.get(
"ANDROID_ROOT", None
):
if sys.platform.startswith("linux") and os.environ.get("ANDROID_ROOT"):
# termux support for android.
# system libraries (zlib) are installed in /system/lib
# headers are at $PREFIX/include

View File

@ -235,6 +235,14 @@ class FpxImageFile(ImageFile.ImageFile):
return ImageFile.ImageFile.load(self)
def close(self):
self.ole.close()
super().close()
def __exit__(self, *args):
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------

View File

@ -1012,7 +1012,7 @@ 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", "")
lindirs = os.environ.get("XDG_DATA_DIRS")
if not lindirs:
# According to the freedesktop spec, XDG_DATA_DIRS should
# default to /usr/share

View File

@ -89,6 +89,14 @@ class MicImageFile(TiffImagePlugin.TiffImageFile):
def tell(self):
return self.frame
def close(self):
self.ole.close()
super().close()
def __exit__(self, *args):
self.ole.close()
super().__exit__()
#
# --------------------------------------------------------------------

View File

@ -173,6 +173,10 @@ def _save(im, fp, filename, save_all=False):
filter = "DCTDecode"
colorspace = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "RGBA":
filter = "JPXDecode"
colorspace = PdfParser.PdfName("DeviceRGB")
procset = "ImageC" # color images
elif im.mode == "CMYK":
filter = "DCTDecode"
colorspace = PdfParser.PdfName("DeviceCMYK")
@ -199,6 +203,8 @@ def _save(im, fp, filename, save_all=False):
)
elif filter == "DCTDecode":
Image.SAVE["JPEG"](im, op, filename)
elif filter == "JPXDecode":
Image.SAVE["JPEG2000"](im, op, filename)
elif filter == "FlateDecode":
ImageFile._save(im, op, [("zip", (0, 0) + im.size, 0, im.mode)])
elif filter == "RunLengthDecode":

105
src/PIL/QoiImagePlugin.py Normal file
View File

@ -0,0 +1,105 @@
#
# The Python Imaging Library.
#
# QOI support for PIL
#
# See the README file for information on usage and redistribution.
#
import os
from . import Image, ImageFile
from ._binary import i32be as i32
from ._binary import o8
def _accept(prefix):
return prefix[:4] == b"qoif"
class QoiImageFile(ImageFile.ImageFile):
format = "QOI"
format_description = "Quite OK Image"
def _open(self):
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))
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)]
class QoiDecoder(ImageFile.PyDecoder):
_pulls_fd = True
def _add_to_previous_pixels(self, value):
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):
self._previously_seen_pixels = {}
self._previous_pixel = None
self._add_to_previous_pixels(b"".join(o8(i) for i in (0, 0, 0, 255)))
data = bytearray()
bands = Image.getmodebands(self.mode)
while len(data) < self.state.xsize * self.state.ysize * bands:
byte = self.fd.read(1)[0]
if byte == 0b11111110: # QOI_OP_RGB
value = self.fd.read(3) + o8(255)
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._previous_pixel[3],)
elif op == 2: # 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 += (self._previous_pixel[3],)
elif op == 3: # 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))
return -1, 0
Image.register_open(QoiImageFile.format, QoiImageFile, _accept)
Image.register_decoder("qoi", QoiDecoder)
Image.register_extension(QoiImageFile.format, ".qoi")

View File

@ -425,6 +425,9 @@ class IFDRational(Rational):
__ceil__ = _delegate("__ceil__")
__floor__ = _delegate("__floor__")
__round__ = _delegate("__round__")
# Python >= 3.11
if hasattr(Fraction, "__int__"):
__int__ = _delegate("__int__")
class ImageFileDirectory_v2(MutableMapping):

View File

@ -59,6 +59,7 @@ _plugins = [
"PngImagePlugin",
"PpmImagePlugin",
"PsdImagePlugin",
"QoiImagePlugin",
"SgiImagePlugin",
"SpiderImagePlugin",
"SunImagePlugin",

View File

@ -955,6 +955,13 @@ addTransparencyFlagToModule(PyObject *m) {
static int
setup_module(PyObject *m) {
#ifdef HAVE_WEBPANIM
/* Ready object types */
if (PyType_Ready(&WebPAnimDecoder_Type) < 0 ||
PyType_Ready(&WebPAnimEncoder_Type) < 0) {
return -1;
}
#endif
PyObject *d = PyModule_GetDict(m);
addMuxFlagToModule(m);
addAnimFlagToModule(m);
@ -963,13 +970,6 @@ setup_module(PyObject *m) {
PyDict_SetItemString(
d, "webpdecoder_version", PyUnicode_FromString(WebPDecoderVersion_str()));
#ifdef HAVE_WEBPANIM
/* Ready object types */
if (PyType_Ready(&WebPAnimDecoder_Type) < 0 ||
PyType_Ready(&WebPAnimEncoder_Type) < 0) {
return -1;
}
#endif
return 0;
}

View File

@ -195,20 +195,21 @@ _releasedc(ImagingDisplayObject *display, PyObject *args) {
static PyObject *
_frombytes(ImagingDisplayObject *display, PyObject *args) {
char *ptr;
Py_ssize_t bytes;
Py_buffer buffer;
if (!PyArg_ParseTuple(args, "y#:frombytes", &ptr, &bytes)) {
if (!PyArg_ParseTuple(args, "y*:frombytes", &buffer)) {
return NULL;
}
if (display->dib->ysize * display->dib->linesize != bytes) {
if (display->dib->ysize * display->dib->linesize != buffer.len) {
PyBuffer_Release(&buffer);
PyErr_SetString(PyExc_ValueError, "wrong size");
return NULL;
}
memcpy(display->dib->bits, ptr, bytes);
memcpy(display->dib->bits, buffer.buf, buffer.len);
PyBuffer_Release(&buffer);
Py_INCREF(Py_None);
return Py_None;
}

View File

@ -487,6 +487,10 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) {
goto quick_exit;
}
if (strcmp(im->mode, "RGBA") == 0) {
image->comps[3].alpha = 1;
}
opj_set_error_handler(codec, j2k_error, context);
opj_set_info_handler(codec, j2k_warn, context);
opj_set_warning_handler(codec, j2k_warn, context);