From 2203afeafa76519fcc01682d8c35e5c5d569d7c6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 1 Apr 2023 19:36:06 +1100 Subject: [PATCH 01/40] Do not set size unnecessarily if image failed to open --- src/PIL/EpsImagePlugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 1c88d22c7..2f7fee901 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -354,7 +354,6 @@ class EpsImageFile(ImageFile.ImageFile): check_required_header_comments() if not self._size: - self._size = 1, 1 # errors if this isn't set. why (1,1)? msg = "cannot determine EPS bounding box" raise OSError(msg) From dd15f15d08bf3fd32c41ef9f2502286778c9f993 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Apr 2023 09:06:20 +1000 Subject: [PATCH 02/40] Added further field sizes --- Tests/test_file_tiff.py | 9 ++++++++- src/PIL/TiffImagePlugin.py | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 97a02ac96..43181d1b3 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -96,10 +96,17 @@ class TestFileTiff: assert_image_similar_tofile(im, "Tests/images/pil136.png", 1) - def test_bigtiff(self): + def test_bigtiff(self, tmp_path): with Image.open("Tests/images/hopper_bigtiff.tif") as im: assert_image_equal_tofile(im, "Tests/images/hopper.tif") + with Image.open("Tests/images/hopper_bigtiff.tif") as im: + # multistrip support not yet implemented + del im.tag_v2[273] + + outfile = str(tmp_path / "temp.tif") + im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) + def test_set_legacy_api(self): ifd = TiffImagePlugin.ImageFileDirectory_v2() with pytest.raises(Exception) as e: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 3d4d0910a..5b7d3f302 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1892,6 +1892,10 @@ class AppendingTiffWriter: 8, # srational 4, # float 8, # double + 4, # ifd + 2, # unicode + 4, # complex + 8, # long8 ] # StripOffsets = 273 From 099d696dc7d3c349265ae3cdbb5f949bca1e2866 Mon Sep 17 00:00:00 2001 From: rrcgat Date: Sat, 15 Apr 2023 18:24:19 +0800 Subject: [PATCH 03/40] Fix ImageGrab with wl-paste --- Tests/test_imagegrab.py | 19 +++++++++++++++++++ src/PIL/ImageGrab.py | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index fa88065f4..e7c2c6c9f 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -98,3 +98,22 @@ $ms = new-object System.IO.MemoryStream(, $bytes) im = ImageGrab.grabclipboard() assert_image_equal_tofile(im, "Tests/images/hopper.png") + + @pytest.mark.skipif( + ( + sys.platform != "linux" + or not all(shutil.which(cmd) for cmd in ["wl-paste", "wl-copy"]) + ), + reason="Linux with wl-clipboard only", + ) + @pytest.mark.parametrize( + "image_path", ["Tests/images/hopper.gif", "Tests/images/hopper.png"] + ) + def test_grabclipboard_wl_clipboard(self, image_path): + with open(image_path, mode="rb") as raw_image: + try: + subprocess.call(["wl-copy"], stdin=raw_image) + im = ImageGrab.grabclipboard() + assert_image_equal_tofile(im, image_path) + except OSError as e: + pytest.skip(str(e)) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 982f77f20..175eb4671 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -135,6 +135,12 @@ def grabclipboard(): else: if shutil.which("wl-paste"): args = ["wl-paste"] + output = subprocess.check_output(["wl-paste", "-l"]).decode() + mime_types = output.splitlines() + for image_type in ["image/gif", "image/png"]: + if image_type in mime_types: + args.extend(["-t", image_type]) + break elif shutil.which("xclip"): args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] else: From 6d12581385688c3af964e6707a1b4ed2651d31a5 Mon Sep 17 00:00:00 2001 From: Carl Weaver Date: Sun, 16 Apr 2023 15:37:38 +0800 Subject: [PATCH 04/40] Update src/PIL/ImageGrab.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/ImageGrab.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 175eb4671..6550a7706 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -136,10 +136,11 @@ def grabclipboard(): if shutil.which("wl-paste"): args = ["wl-paste"] output = subprocess.check_output(["wl-paste", "-l"]).decode() - mime_types = output.splitlines() - for image_type in ["image/gif", "image/png"]: - if image_type in mime_types: - args.extend(["-t", image_type]) + clipboard_mimetypes = output.splitlines() + Image.preinit() + for mimetype in Image.MIME.values(): + if mimetype in clipboard_mimetypes: + args.extend(["-t", mimetype]) break elif shutil.which("xclip"): args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] From 3d54b8e2b2419255a6b5a74dd0f2841ea4de7416 Mon Sep 17 00:00:00 2001 From: rrcgat Date: Sun, 16 Apr 2023 15:41:14 +0800 Subject: [PATCH 05/40] Remove useless try catch block --- Tests/test_imagegrab.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index e7c2c6c9f..703472c4a 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -111,9 +111,6 @@ $ms = new-object System.IO.MemoryStream(, $bytes) ) def test_grabclipboard_wl_clipboard(self, image_path): with open(image_path, mode="rb") as raw_image: - try: - subprocess.call(["wl-copy"], stdin=raw_image) - im = ImageGrab.grabclipboard() - assert_image_equal_tofile(im, image_path) - except OSError as e: - pytest.skip(str(e)) + subprocess.call(["wl-copy"], stdin=raw_image) + im = ImageGrab.grabclipboard() + assert_image_equal_tofile(im, image_path) From aa2e662995eaf67e2f9f53d6817a173c12b45a19 Mon Sep 17 00:00:00 2001 From: rrcgat Date: Mon, 17 Apr 2023 16:44:43 +0800 Subject: [PATCH 06/40] Add sway and wl-clipboard dependencies to GitHub CI workflow --- .ci/install.sh | 3 ++- .github/workflows/test.yml | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.ci/install.sh b/.ci/install.sh index 17c349ab1..d5cbd8248 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -22,7 +22,8 @@ set -e if [[ $(uname) != CYGWIN* ]]; then sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\ ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\ - cmake meson imagemagick libharfbuzz-dev libfribidi-dev + cmake meson imagemagick libharfbuzz-dev libfribidi-dev\ + sway wl-clipboard fi python3 -m pip install --upgrade pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fced6113b..53b7ee688 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,7 +84,15 @@ jobs: python3 -m pip install pytest-reverse fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - xvfb-run -s '-screen 0 1024x768x24' .ci/test.sh + export XDG_RUNTIME_DIR="/tmp/headless-sway" + export SWAYSOCK="$XDG_RUNTIME_DIR/sway.sock" + export WLR_BACKENDS=headless + export WLR_LIBINPUT_NO_DEVICES=1 + mkdir "$XDG_RUNTIME_DIR" + xvfb-run -s '-screen 0 1024x768x24'\ + sway -V -d -c /dev/null& + export WAYLAND_DISPLAY=wayland-1 + .ci/test.sh else .ci/test.sh fi From b7585b0597855f15ccf998d84684d90488a1133a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Apr 2023 10:27:36 +1000 Subject: [PATCH 07/40] Removed unnecessary settings --- .github/workflows/test.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53b7ee688..afb8fb56c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,13 +84,7 @@ jobs: python3 -m pip install pytest-reverse fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - export XDG_RUNTIME_DIR="/tmp/headless-sway" - export SWAYSOCK="$XDG_RUNTIME_DIR/sway.sock" - export WLR_BACKENDS=headless - export WLR_LIBINPUT_NO_DEVICES=1 - mkdir "$XDG_RUNTIME_DIR" - xvfb-run -s '-screen 0 1024x768x24'\ - sway -V -d -c /dev/null& + xvfb-run -s '-screen 0 1024x768x24' sway& export WAYLAND_DISPLAY=wayland-1 .ci/test.sh else From f15d7265f779c04d4e01b425f1e8b7211422a7dd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Apr 2023 10:33:31 +1000 Subject: [PATCH 08/40] Call init() if mimetype is not found with preinit() --- Tests/test_imagegrab.py | 11 +++++------ src/PIL/ImageGrab.py | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 703472c4a..065c9c1b5 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -106,11 +106,10 @@ $ms = new-object System.IO.MemoryStream(, $bytes) ), reason="Linux with wl-clipboard only", ) - @pytest.mark.parametrize( - "image_path", ["Tests/images/hopper.gif", "Tests/images/hopper.png"] - ) - def test_grabclipboard_wl_clipboard(self, image_path): - with open(image_path, mode="rb") as raw_image: - subprocess.call(["wl-copy"], stdin=raw_image) + @pytest.mark.parametrize("ext", ("gif", "png", "ico")) + def test_grabclipboard_wl_clipboard(self, ext): + image_path = "Tests/images/hopper." + ext + with open(image_path, "rb") as fp: + subprocess.call(["wl-copy"], stdin=fp) im = ImageGrab.grabclipboard() assert_image_equal_tofile(im, image_path) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 6550a7706..55b50fb48 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -137,11 +137,19 @@ def grabclipboard(): args = ["wl-paste"] output = subprocess.check_output(["wl-paste", "-l"]).decode() clipboard_mimetypes = output.splitlines() + + def find_mimetype(): + for mime in Image.MIME.values(): + if mime in clipboard_mimetypes: + return mime + Image.preinit() - for mimetype in Image.MIME.values(): - if mimetype in clipboard_mimetypes: - args.extend(["-t", mimetype]) - break + mimetype = find_mimetype() + if not mimetype: + Image.init() + mimetype = find_mimetype() + if mimetype: + args.extend(["-t", mimetype]) elif shutil.which("xclip"): args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] else: From c5f90af56c7ca2b0e1ee3d3a95b5e7cd5291df5a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 5 May 2023 07:19:13 +1000 Subject: [PATCH 09/40] Updated xz to 5.4.3 --- winbuild/build_prepare.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 9b5fc5d18..05df77a68 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -152,9 +152,9 @@ deps = { "libs": [r"*.lib"], }, "xz": { - "url": SF_PROJECTS + "/lzmautils/files/xz-5.4.2.tar.gz/download", - "filename": "xz-5.4.2.tar.gz", - "dir": "xz-5.4.2", + "url": SF_PROJECTS + "/lzmautils/files/xz-5.4.3.tar.gz/download", + "filename": "xz-5.4.3.tar.gz", + "dir": "xz-5.4.3", "license": "COPYING", "build": [ *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), From f67fcf131a53f7436a2f4a540ed251c927af2c05 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 8 May 2023 11:58:05 +1000 Subject: [PATCH 10/40] If the clipboard fails to open on Windows, wait and try again --- src/display.c | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/display.c b/src/display.c index e8e7b62c2..754a6ae78 100644 --- a/src/display.c +++ b/src/display.c @@ -437,8 +437,14 @@ PyImaging_GrabClipboardWin32(PyObject *self, PyObject *args) { LPCSTR format_names[] = {"DIB", "DIB", "file", "png", NULL}; if (!OpenClipboard(NULL)) { - PyErr_SetString(PyExc_OSError, "failed to open clipboard"); - return NULL; + // Maybe the clipboard is temporarily in use by another process. + // Wait and try again + Sleep(500); + + if (!OpenClipboard(NULL)) { + PyErr_SetString(PyExc_OSError, "failed to open clipboard"); + return NULL; + } } // find best format as set by clipboard owner From a0b691a219d274a561784781476cc952e79c1b8b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 9 May 2023 12:12:16 +1000 Subject: [PATCH 11/40] Fixed combining single duration across duplicate PNG frames --- Tests/test_file_apng.py | 6 ++++++ src/PIL/PngImagePlugin.py | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index f78c086eb..c62231cd4 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -440,6 +440,12 @@ def test_apng_save_duration_loop(tmp_path): assert im.n_frames == 1 assert im.info.get("duration") == 750 + # test removal of duplicated frames with a single duration + frame.save(test_file, save_all=True, append_images=[frame, frame], duration=500) + with Image.open(test_file) as im: + assert im.n_frames == 1 + assert im.info.get("duration") == 1500 + # test info duration frame.info["duration"] = 750 frame.save(test_file, save_all=True) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 82a74b267..aaf242b1d 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1146,11 +1146,14 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) and prev_disposal == encoderinfo.get("disposal") and prev_blend == encoderinfo.get("blend") ): - if isinstance(duration, (list, tuple)): - previous["encoderinfo"]["duration"] += encoderinfo["duration"] + previous["encoderinfo"]["duration"] += encoderinfo.get( + "duration", duration + ) continue else: bbox = None + if "duration" not in encoderinfo: + encoderinfo["duration"] = duration im_frames.append({"im": im_frame, "bbox": bbox, "encoderinfo": encoderinfo}) # animation control @@ -1175,7 +1178,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) im_frame = im_frame.crop(bbox) size = im_frame.size encoderinfo = frame_data["encoderinfo"] - frame_duration = int(round(encoderinfo.get("duration", duration))) + frame_duration = int(round(encoderinfo["duration"])) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control From b39c807dde8909b1ce4afd85f37563165224e073 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 18 May 2023 22:14:40 +1000 Subject: [PATCH 12/40] Removed rectangle example from co-ordinate system documentation --- docs/handbook/concepts.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index e40ed4687..e0975a121 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -95,9 +95,8 @@ in the upper left corner. Note that the coordinates refer to the implied pixel corners; the centre of a pixel addressed as (0, 0) actually lies at (0.5, 0.5). Coordinates are usually passed to the library as 2-tuples (x, y). Rectangles -are represented as 4-tuples, with the upper left corner given first. For -example, a rectangle covering all of an 800x600 pixel image is written as (0, -0, 800, 600). +are represented as 4-tuples, (x1, y1, x2, y2), with the upper left corner given +first. Palette ------- From 546f6cbc27e178bfc742d8216a1af508b3e26e02 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 20 May 2023 17:11:43 +1000 Subject: [PATCH 13/40] Replaced absolute PIL import with relative import --- src/PIL/IcnsImagePlugin.py | 4 ++-- src/PIL/ImageCms.py | 6 +++--- src/PIL/ImageShow.py | 2 +- src/PIL/SpiderImagePlugin.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index c2f050edd..27cb89f73 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -22,11 +22,11 @@ import os import struct import sys -from PIL import Image, ImageFile, PngImagePlugin, features +from . import Image, ImageFile, PngImagePlugin, features enable_jpeg2k = features.check_codec("jpg_2000") if enable_jpeg2k: - from PIL import Jpeg2KImagePlugin + from . import Jpeg2KImagePlugin MAGIC = b"icns" HEADERSIZE = 8 diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 38cbab19c..3a337f9f2 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -18,10 +18,10 @@ import sys from enum import IntEnum -from PIL import Image +from . import Image try: - from PIL import _imagingcms + from . import _imagingcms except ImportError as ex: # Allow error import for doc purposes, but error out when accessing # anything in core. @@ -271,7 +271,7 @@ def get_display_profile(handle=None): if sys.platform != "win32": return None - from PIL import ImageWin + from . import ImageWin if isinstance(handle, ImageWin.HDC): profile = core.get_display_profile_win32(handle, 1) diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index 3f68a2696..8b1c3f8bb 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -17,7 +17,7 @@ import subprocess import sys from shlex import quote -from PIL import Image +from . import Image _viewers = [] diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index eac27e679..5614957c1 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -36,7 +36,7 @@ import os import struct import sys -from PIL import Image, ImageFile +from . import Image, ImageFile def isInt(f): @@ -191,7 +191,7 @@ class SpiderImageFile(ImageFile.ImageFile): # returns a ImageTk.PhotoImage object, after rescaling to 0..255 def tkPhotoImage(self): - from PIL import ImageTk + from . import ImageTk return ImageTk.PhotoImage(self.convert2byte(), palette=256) From dc6d0641b3c5e93b26b214a251754e549b84260a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 May 2023 19:39:25 +1000 Subject: [PATCH 14/40] Updated redirected URLs --- codecov.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codecov.yml b/codecov.yml index f3afccc1c..b794632fa 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,9 @@ -# Documentation: https://docs.codecov.io/docs/codecov-yaml +# Documentation: https://docs.codecov.com/docs/codecov-yaml codecov: # Avoid "Missing base report" due to committing CHANGES.rst with "[CI skip]" # https://github.com/codecov/support/issues/363 - # https://docs.codecov.io/docs/comparing-commits + # https://docs.codecov.com/docs/comparing-commits allow_coverage_offsets: true comment: false From fffcb558f64f2350789b67ec5eb55681408a93d5 Mon Sep 17 00:00:00 2001 From: rrcgat Date: Tue, 23 May 2023 18:44:25 +0800 Subject: [PATCH 15/40] Use image/png mime type for ImageGrab (wl-paste) if possible, otherwise the first mime type taken --- src/PIL/ImageGrab.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 3771e6a79..b7f416321 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -147,15 +147,12 @@ def grabclipboard(): clipboard_mimetypes = output.splitlines() def find_mimetype(): - for mime in Image.MIME.values(): - if mime in clipboard_mimetypes: - return mime + if "image/png" in clipboard_mimetypes: + return "image/png" + if clipboard_mimetypes: + return clipboard_mimetypes[0] - Image.preinit() mimetype = find_mimetype() - if not mimetype: - Image.init() - mimetype = find_mimetype() if mimetype: args.extend(["-t", mimetype]) elif shutil.which("xclip"): From bce0f0d5a64c008b9d9ffbea33e98a79ffdae8c3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 May 2023 21:25:11 +1000 Subject: [PATCH 16/40] Moved function code inline --- src/PIL/ImageGrab.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index b7f416321..7f6d50af4 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -142,17 +142,16 @@ def grabclipboard(): return None else: if shutil.which("wl-paste"): - args = ["wl-paste"] output = subprocess.check_output(["wl-paste", "-l"]).decode() - clipboard_mimetypes = output.splitlines() + mimetypes = output.splitlines() + if "image/png" in mimetypes: + mimetype = "image/png" + elif mimetypes: + mimetype = mimetypes[0] + else: + mimetype = None - def find_mimetype(): - if "image/png" in clipboard_mimetypes: - return "image/png" - if clipboard_mimetypes: - return clipboard_mimetypes[0] - - mimetype = find_mimetype() + args = ["wl-paste"] if mimetype: args.extend(["-t", mimetype]) elif shutil.which("xclip"): From 26d5f4fcb1fa23c920f42c56e187de092da544a7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 May 2023 21:27:55 +1000 Subject: [PATCH 17/40] Use tuple instead of list --- Tests/test_imagegrab.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index 065c9c1b5..f8059eca4 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -102,7 +102,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) @pytest.mark.skipif( ( sys.platform != "linux" - or not all(shutil.which(cmd) for cmd in ["wl-paste", "wl-copy"]) + or not all(shutil.which(cmd) for cmd in ("wl-paste", "wl-copy")) ), reason="Linux with wl-clipboard only", ) @@ -111,5 +111,5 @@ $ms = new-object System.IO.MemoryStream(, $bytes) image_path = "Tests/images/hopper." + ext with open(image_path, "rb") as fp: subprocess.call(["wl-copy"], stdin=fp) - im = ImageGrab.grabclipboard() - assert_image_equal_tofile(im, image_path) + im = ImageGrab.grabclipboard() + assert_image_equal_tofile(im, image_path) From b8719033ca91ef57f58128d32df675457431bbce Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 May 2023 22:53:16 +1000 Subject: [PATCH 18/40] Removed unused INT64 definition --- src/libImaging/ImPlatform.h | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libImaging/ImPlatform.h b/src/libImaging/ImPlatform.h index af9996ca9..94781f9ec 100644 --- a/src/libImaging/ImPlatform.h +++ b/src/libImaging/ImPlatform.h @@ -58,12 +58,6 @@ #error Cannot find required 32-bit integer type #endif -#if SIZEOF_LONG == 8 -#define INT64 long -#elif SIZEOF_LONG_LONG == 8 -#define INT64 long -#endif - #define INT8 signed char #define UINT8 unsigned char From 922e239cca2a45d239dd02f0a4b85b72a7918917 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 24 May 2023 08:55:14 +1000 Subject: [PATCH 19/40] Fixed saving multiple 1 mode images to GIF --- Tests/test_file_gif.py | 13 +++++++++++++ src/PIL/GifImagePlugin.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 8522f486a..0e50ee1ab 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -252,6 +252,19 @@ def test_roundtrip_save_all(tmp_path): assert reread.n_frames == 5 +def test_roundtrip_save_all_1(tmp_path): + out = str(tmp_path / "temp.gif") + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + im.save(out, save_all=True, append_images=[im2]) + + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == 0 + + reloaded.seek(1) + assert reloaded.getpixel((0, 0)) == 255 + + @pytest.mark.parametrize( "path, mode", ( diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index eadee1560..2f92e9467 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -879,7 +879,7 @@ def _get_palette_bytes(im): :param im: Image object :returns: Bytes, len<=768 suitable for inclusion in gif header """ - return im.palette.palette + return im.palette.palette if im.palette else b"" def _get_background(im, info_background): From 117618b01f959f016833158b7b128e896c6d38b6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 24 May 2023 22:47:43 +1000 Subject: [PATCH 20/40] Update CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 626b8b231..190751ad2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 10.0.0 (unreleased) ------------------- +- Improved wl-paste mimetype handling in ImageGrab #7094 + [rrcgat, radarhere] + - Added _repr_jpeg_() for IPython display_jpeg #7135 [n3011, radarhere, nulano] From e6d7f1f3477b915d4f2fb7d71d609af74e47a444 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 26 May 2023 19:52:13 +1000 Subject: [PATCH 21/40] Install setuptools on Windows --- .github/workflows/test-windows.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index a00880111..076b80839 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -65,8 +65,8 @@ jobs: - name: Print build system information run: python3 .github/workflows/system-info.py - - name: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml - run: python3 -m pip install wheel pytest pytest-cov pytest-timeout defusedxml + - name: python3 -m pip install setuptools wheel pytest pytest-cov pytest-timeout defusedxml + run: python3 -m pip install setuptools wheel pytest pytest-cov pytest-timeout defusedxml - name: Install dependencies id: install From 7a5ddc1712240b21d89581602acbb851c3897e4a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 27 May 2023 10:28:38 +1000 Subject: [PATCH 22/40] Do not test PyQt6 on Python 3.12 --- .ci/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/install.sh b/.ci/install.sh index d5cbd8248..6e87d386d 100755 --- a/.ci/install.sh +++ b/.ci/install.sh @@ -42,7 +42,7 @@ if [[ $(uname) != CYGWIN* ]]; then if ! [ "$GHA_PYTHON_VERSION" == "3.12-dev" ]; then python3 -m pip install numpy ; fi # PyQt6 doesn't support PyPy3 - if [[ $GHA_PYTHON_VERSION == 3.* ]]; then + if [[ "$GHA_PYTHON_VERSION" != "3.12-dev" && $GHA_PYTHON_VERSION == 3.* ]]; then sudo apt-get -qq install libegl1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxkbcommon-x11-0 python3 -m pip install pyqt6 fi From 07eccd9798387a79db84557102d34de2f2f4c28d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 26 May 2023 19:14:56 +1000 Subject: [PATCH 23/40] Fixed calling putpalette() on L and LA images before load() --- Tests/test_image_putpalette.py | 8 ++++++++ src/libImaging/Unpack.c | 2 ++ 2 files changed, 10 insertions(+) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index 3b29769a7..665e08a7e 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -32,6 +32,14 @@ def test_putpalette(): with pytest.raises(ValueError): palette("YCbCr") + with Image.open("Tests/images/hopper_gray.jpg") as im: + assert im.mode == "L" + im.putpalette(list(range(256)) * 3) + + with Image.open("Tests/images/la.tga") as im: + assert im.mode == "LA" + im.putpalette(list(range(256)) * 3) + def test_imagepalette(): im = hopper("P") diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index a0fa22c7d..206403ba6 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -1552,10 +1552,12 @@ static struct { {"P", "P;4L", 4, unpackP4L}, {"P", "P", 8, copy1}, {"P", "P;R", 8, unpackLR}, + {"P", "L", 8, copy1}, /* palette w. alpha */ {"PA", "PA", 16, unpackLA}, {"PA", "PA;L", 16, unpackLAL}, + {"PA", "LA", 16, unpackLA}, /* true colour */ {"RGB", "RGB", 24, ImagingUnpackRGB}, From c45019fe0ccbf54c925aba914329371a7f188a48 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 29 May 2023 12:28:03 +1000 Subject: [PATCH 24/40] Replaced deprecated Py_FileSystemDefaultEncoding for Python >= 3.12 --- src/_imagingft.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/_imagingft.c b/src/_imagingft.c index 78e3f7f10..80f862bb7 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -132,6 +132,27 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { return NULL; } +#if PY_MAJOR_VERSION > 3 || PY_MINOR_VERSION > 11 + PyConfig config; + PyConfig_InitPythonConfig(&config); + if (!PyArg_ParseTupleAndKeywords( + args, + kw, + "etf|nsy#n", + kwlist, + config.filesystem_encoding, + &filename, + &size, + &index, + &encoding, + &font_bytes, + &font_bytes_size, + &layout_engine)) { + PyConfig_Clear(&config); + return NULL; + } + PyConfig_Clear(&config); +#else if (!PyArg_ParseTupleAndKeywords( args, kw, @@ -147,6 +168,7 @@ getfont(PyObject *self_, PyObject *args, PyObject *kw) { &layout_engine)) { return NULL; } +#endif self = PyObject_New(FontObject, &Font_Type); if (!self) { From e01a0195dd9f54b8174f322d47d4f618f0cf6c50 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 1 Jun 2023 22:53:07 +1000 Subject: [PATCH 25/40] Removed duplicate config --- .editorconfig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index 449530717..d74549fe2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,10 +13,6 @@ indent_style = space trim_trailing_whitespace = true -[*.rst] -# Four-space indentation -indent_size = 4 - [*.yml] # Two-space indentation indent_size = 2 From ea3e4242d8fd8bb5cfc9e528f863ce16e20b529f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jun 2023 08:07:05 +1000 Subject: [PATCH 26/40] Removed files and types override --- .pre-commit-config.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4882a317f..0ddc6beb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,9 +4,6 @@ repos: hooks: - id: black args: [--target-version=py38] - # Only .py files, until https://github.com/psf/black/issues/402 resolved - files: \.py$ - types: [] - repo: https://github.com/PyCQA/isort rev: 5.12.0 From 3693b84ba0b44f71119cec73c8517ec32d1774b5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 2 Jun 2023 09:21:47 +1000 Subject: [PATCH 27/40] Lint fixes --- docs/Guardfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Guardfile b/docs/Guardfile index b689b079a..6cbf07b06 100755 --- a/docs/Guardfile +++ b/docs/Guardfile @@ -2,7 +2,7 @@ from livereload.compiler import shell from livereload.task import Task -Task.add('*.rst', shell('make html')) -Task.add('*/*.rst', shell('make html')) -Task.add('Makefile', shell('make html')) -Task.add('conf.py', shell('make html')) +Task.add("*.rst", shell("make html")) +Task.add("*/*.rst", shell("make html")) +Task.add("Makefile", shell("make html")) +Task.add("conf.py", shell("make html")) From 97bd53392ce136617ead36c11d50def9d32ab3e9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 5 Jun 2023 18:36:41 +1000 Subject: [PATCH 28/40] Do not use temporary file when grabbing clipboard on Linux --- src/PIL/ImageGrab.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 7f6d50af4..39ecdf420 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -15,6 +15,7 @@ # See the README file for information on usage and redistribution. # +import io import os import shutil import subprocess @@ -128,8 +129,6 @@ def grabclipboard(): files = data[o:].decode("mbcs").split("\0") return files[: files.index("")] if isinstance(data, bytes): - import io - data = io.BytesIO(data) if fmt == "png": from . import PngImagePlugin @@ -159,13 +158,12 @@ def grabclipboard(): else: msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux" raise NotImplementedError(msg) - fh, filepath = tempfile.mkstemp() - err = subprocess.run(args, stdout=fh, stderr=subprocess.PIPE).stderr - os.close(fh) + p = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + err = p.stderr if err: msg = f"{args[0]} error: {err.strip().decode()}" raise ChildProcessError(msg) - im = Image.open(filepath) + data = io.BytesIO(p.stdout) + im = Image.open(data) im.load() - os.unlink(filepath) return im From 3b65261c966648e5d4f87cd49bb12cba5345547d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 6 Jun 2023 17:54:55 +1000 Subject: [PATCH 29/40] Remove temporary file when error is raised --- src/PIL/EpsImagePlugin.py | 7 +++++++ src/PIL/JpegImagePlugin.py | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 1c88d22c7..bdac874c4 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -134,6 +134,13 @@ def Ghostscript(tile, size, fp, scale=1, transparency=False): if gs_windows_binary is not None: if not gs_windows_binary: + try: + os.unlink(outfile) + if infile_temp: + os.unlink(infile_temp) + except OSError: + pass + msg = "Unable to locate Ghostscript on paths" raise OSError(msg) command[0] = gs_windows_binary diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 5dd1a61af..dfc7e6e9f 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -457,6 +457,11 @@ class JpegImageFile(ImageFile.ImageFile): if os.path.exists(self.filename): subprocess.check_call(["djpeg", "-outfile", path, self.filename]) else: + try: + os.unlink(path) + except OSError: + pass + msg = "Invalid Filename" raise ValueError(msg) From 97df237dc81c930d983b4025b7b3a97d043dfd7c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 6 Jun 2023 18:04:39 +1000 Subject: [PATCH 30/40] Moved test into separate function --- Tests/test_file_apng.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index c62231cd4..a22ac581d 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -440,12 +440,6 @@ def test_apng_save_duration_loop(tmp_path): assert im.n_frames == 1 assert im.info.get("duration") == 750 - # test removal of duplicated frames with a single duration - frame.save(test_file, save_all=True, append_images=[frame, frame], duration=500) - with Image.open(test_file) as im: - assert im.n_frames == 1 - assert im.info.get("duration") == 1500 - # test info duration frame.info["duration"] = 750 frame.save(test_file, save_all=True) @@ -453,6 +447,17 @@ def test_apng_save_duration_loop(tmp_path): assert im.info.get("duration") == 750 +def test_apng_save_duplicate_duration(tmp_path): + test_file = str(tmp_path / "temp.png") + frame = Image.new("RGB", (1, 1)) + + # Test a single duration is correctly combined across duplicate frames + frame.save(test_file, save_all=True, append_images=[frame, frame], duration=500) + with Image.open(test_file) as im: + assert im.n_frames == 1 + assert im.info.get("duration") == 1500 + + def test_apng_save_disposal(tmp_path): test_file = str(tmp_path / "temp.png") size = (128, 64) From 7c533276f28518ec2825e1cae3f0df427b5c565b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 6 Jun 2023 19:53:50 +1000 Subject: [PATCH 31/40] Update CHANGES.rst [ci skip] --- CHANGES.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 190751ad2..c51f8fb94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,27 @@ Changelog (Pillow) 10.0.0 (unreleased) ------------------- +- Fixed combining single duration across duplicate APNG frames #7146 + [radarhere] + +- Remove temporary file when error is raised #7148 + [radarhere] + +- Do not use temporary file when grabbing clipboard on Linux #7200 + [radarhere] + +- If the clipboard fails to open on Windows, wait and try again #7141 + [radarhere] + +- Fixed saving multiple 1 mode frames to GIF #7181 + [radarhere] + +- Replaced absolute PIL import with relative import #7173 + [radarhere] + +- Replaced deprecated Py_FileSystemDefaultEncoding for Python >= 3.12 #7192 + [radarhere] + - Improved wl-paste mimetype handling in ImageGrab #7094 [rrcgat, radarhere] From 15edb6d625f94e0f7e9047ab76ed08762ab2f53a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 7 Jun 2023 22:33:55 +1000 Subject: [PATCH 32/40] Fixed signedness comparison warning --- src/libImaging/Jpeg2KEncode.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libImaging/Jpeg2KEncode.c b/src/libImaging/Jpeg2KEncode.c index 0d7e896b7..de8586706 100644 --- a/src/libImaging/Jpeg2KEncode.c +++ b/src/libImaging/Jpeg2KEncode.c @@ -464,7 +464,7 @@ j2k_encode_entry(Imaging im, ImagingCodecState state) { } if (!context->num_resolutions) { - while (tile_width < (1 << (params.numresolution - 1U)) || tile_height < (1 << (params.numresolution - 1U))) { + while (tile_width < (1U << (params.numresolution - 1U)) || tile_height < (1U << (params.numresolution - 1U))) { params.numresolution -= 1; } } From da6b2ec28506a132fad9674e1badb1624aed7a8b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 9 Jun 2023 10:47:20 +1000 Subject: [PATCH 33/40] Document order of kernel weights --- src/PIL/ImageFilter.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 63d6dcf5c..33bc7cc2e 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -35,7 +35,7 @@ class BuiltinFilter(MultibandFilter): class Kernel(BuiltinFilter): """ - Create a convolution kernel. The current version only + Create a convolution kernel. The current version only supports 3x3 and 5x5 integer and floating point kernels. In the current version, kernels can only be applied to @@ -43,9 +43,10 @@ class Kernel(BuiltinFilter): :param size: Kernel size, given as (width, height). In the current version, this must be (3,3) or (5,5). - :param kernel: A sequence containing kernel weights. + :param 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 + 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. From 748a4d0fcd517e0e6e86ae15f4be9b0bcf65747d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 14:26:28 +1000 Subject: [PATCH 34/40] Removed unused variable --- src/libImaging/Storage.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libImaging/Storage.c b/src/libImaging/Storage.c index 7cf00ef35..128595f65 100644 --- a/src/libImaging/Storage.c +++ b/src/libImaging/Storage.c @@ -37,8 +37,6 @@ #include "Imaging.h" #include -int ImagingNewCount = 0; - /* -------------------------------------------------------------------- * Standard image object. */ From aeb6e9909e94d1ad6c86ebf04a6db6cd77e016a3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 15:57:05 +1000 Subject: [PATCH 35/40] Removed unused argument --- src/PIL/Image.py | 2 +- src/_imaging.c | 5 ++--- src/libImaging/Filter.c | 2 +- src/libImaging/Imaging.h | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index e0fb6a885..fa70f674b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1254,7 +1254,7 @@ class Image: if ymargin is None: ymargin = xmargin self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) + return self._new(self.im.expand(xmargin, ymargin)) def filter(self, filter): """ diff --git a/src/_imaging.c b/src/_imaging.c index 281f3a4d2..5c6380fee 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -1027,12 +1027,11 @@ _crop(ImagingObject *self, PyObject *args) { static PyObject * _expand_image(ImagingObject *self, PyObject *args) { int x, y; - int mode = 0; - if (!PyArg_ParseTuple(args, "ii|i", &x, &y, &mode)) { + if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return NULL; } - return PyImagingNew(ImagingExpand(self->image, x, y, mode)); + return PyImagingNew(ImagingExpand(self->image, x, y)); } static PyObject * diff --git a/src/libImaging/Filter.c b/src/libImaging/Filter.c index 4b8d2bf05..4dcd368ca 100644 --- a/src/libImaging/Filter.c +++ b/src/libImaging/Filter.c @@ -49,7 +49,7 @@ clip32(float in) { } Imaging -ImagingExpand(Imaging imIn, int xmargin, int ymargin, int mode) { +ImagingExpand(Imaging imIn, int xmargin, int ymargin) { Imaging imOut; int x, y; ImagingSectionCookie cookie; diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index d9ded1852..beec8a8f2 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -290,7 +290,7 @@ ImagingConvertTransparent(Imaging im, const char *mode, int r, int g, int b); extern Imaging ImagingCrop(Imaging im, int x0, int y0, int x1, int y1); extern Imaging -ImagingExpand(Imaging im, int x, int y, int mode); +ImagingExpand(Imaging im, int x, int y); extern Imaging ImagingFill(Imaging im, const void *ink); extern int From 389ad11693deb5ea39b8edf0eb47263582a3f5f0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 17:10:42 +1000 Subject: [PATCH 36/40] Only call text_layout once in getmask2 --- src/PIL/ImageFont.py | 34 +++---- src/_imagingft.c | 215 ++++++++++++++++++++++++++----------------- 2 files changed, 144 insertions(+), 105 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index ea4549cf5..7b4ca5814 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -26,7 +26,6 @@ # import base64 -import math import os import sys import warnings @@ -551,28 +550,23 @@ class FreeTypeFont: :py:mod:`PIL.Image.core` interface module, and the text offset, the gap between the starting coordinate and the first marking """ - size, offset = self.font.getsize( - text, mode, direction, features, language, anchor - ) if start is None: start = (0, 0) - size = tuple(math.ceil(size[i] + stroke_width * 2 + start[i]) for i in range(2)) - offset = offset[0] - stroke_width, offset[1] - stroke_width + im, size, offset = self.font.render( + text, + Image.core.fill, + mode, + direction, + features, + language, + stroke_width, + anchor, + ink, + start[0], + start[1], + Image.MAX_IMAGE_PIXELS, + ) Image._decompression_bomb_check(size) - im = Image.core.fill("RGBA" if mode == "RGBA" else "L", size, 0) - if min(size): - self.font.render( - text, - im.id, - mode, - direction, - features, - language, - stroke_width, - ink, - start[0], - start[1], - ) return im, offset def font_variant( diff --git a/src/_imagingft.c b/src/_imagingft.c index 80f862bb7..95f12eb5a 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -551,73 +551,25 @@ font_getlength(FontObject *self, PyObject *args) { return PyLong_FromLong(length); } -static PyObject * -font_getsize(FontObject *self, PyObject *args) { +static int +bounding_box_and_anchors(FT_Face face, const char *anchor, int horizontal_dir, GlyphInfo *glyph_info, size_t count, int load_flags, int *width, int *height, int *x_offset, int *y_offset) { int position; /* pen position along primary axis, in 26.6 precision */ int advanced; /* pen position along primary axis, in pixels */ int px, py; /* position of current glyph, in pixels */ int x_min, x_max, y_min, y_max; /* text bounding box, in pixels */ int x_anchor, y_anchor; /* offset of point drawn at (0, 0), in pixels */ - int load_flags; /* FreeType load_flags parameter */ int error; - FT_Face face; FT_Glyph glyph; - FT_BBox bbox; /* glyph bounding box */ - GlyphInfo *glyph_info = NULL; /* computed text layout */ - size_t i, count; /* glyph_info index and length */ - int horizontal_dir; /* is primary axis horizontal? */ - int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ - int color = 0; /* is FT_LOAD_COLOR enabled? */ - const char *mode = NULL; - const char *dir = NULL; - const char *lang = NULL; - const char *anchor = NULL; - PyObject *features = Py_None; - PyObject *string; - - /* calculate size and bearing for a given string */ - - if (!PyArg_ParseTuple( - args, "O|zzOzz:getsize", &string, &mode, &dir, &features, &lang, &anchor)) { - return NULL; - } - - horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; - - mask = mode && strcmp(mode, "1") == 0; - color = mode && strcmp(mode, "RGBA") == 0; - - if (anchor == NULL) { - anchor = horizontal_dir ? "la" : "lt"; - } - if (strlen(anchor) != 2) { - goto bad_anchor; - } - - count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); - if (PyErr_Occurred()) { - return NULL; - } - - load_flags = FT_LOAD_DEFAULT; - if (mask) { - load_flags |= FT_LOAD_TARGET_MONO; - } - if (color) { - load_flags |= FT_LOAD_COLOR; - } - + FT_BBox bbox; /* glyph bounding box */ + size_t i; /* glyph_info index */ /* * text bounds are given by: * - bounding boxes of individual glyphs * - pen line, i.e. 0 to `advanced` along primary axis * this means point (0, 0) is part of the text bounding box */ - face = NULL; position = x_min = x_max = y_min = y_max = 0; for (i = 0; i < count; i++) { - face = self->face; - if (horizontal_dir) { px = PIXEL(position + glyph_info[i].x_offset); py = PIXEL(glyph_info[i].y_offset); @@ -640,12 +592,14 @@ font_getsize(FontObject *self, PyObject *args) { error = FT_Load_Glyph(face, glyph_info[i].index, load_flags); if (error) { - return geterror(error); + geterror(error); + return 1; } error = FT_Get_Glyph(face->glyph, &glyph); if (error) { - return geterror(error); + geterror(error); + return 1; } FT_Glyph_Get_CBox(glyph, FT_GLYPH_BBOX_PIXELS, &bbox); @@ -669,13 +623,15 @@ font_getsize(FontObject *self, PyObject *args) { FT_Done_Glyph(glyph); } - if (glyph_info) { - PyMem_Free(glyph_info); - glyph_info = NULL; + if (anchor == NULL) { + anchor = horizontal_dir ? "la" : "lt"; + } + if (strlen(anchor) != 2) { + goto bad_anchor; } x_anchor = y_anchor = 0; - if (face) { + if (count) { if (horizontal_dir) { switch (anchor[0]) { case 'l': // left @@ -693,15 +649,15 @@ font_getsize(FontObject *self, PyObject *args) { } switch (anchor[1]) { case 'a': // ascender - y_anchor = PIXEL(self->face->size->metrics.ascender); + y_anchor = PIXEL(face->size->metrics.ascender); break; case 't': // top y_anchor = y_max; break; case 'm': // middle (ascender + descender) / 2 y_anchor = PIXEL( - (self->face->size->metrics.ascender + - self->face->size->metrics.descender) / + (face->size->metrics.ascender + + face->size->metrics.descender) / 2); break; case 's': // horizontal baseline @@ -711,7 +667,7 @@ font_getsize(FontObject *self, PyObject *args) { y_anchor = y_min; break; case 'd': // descender - y_anchor = PIXEL(self->face->size->metrics.descender); + y_anchor = PIXEL(face->size->metrics.descender); break; default: goto bad_anchor; @@ -751,17 +707,74 @@ font_getsize(FontObject *self, PyObject *args) { } } } - - return Py_BuildValue( - "(ii)(ii)", - (x_max - x_min), - (y_max - y_min), - (-x_anchor + x_min), - -(-y_anchor + y_max)); + *width = x_max - x_min; + *height = y_max - y_min; + *x_offset = -x_anchor + x_min; + *y_offset = -(-y_anchor + y_max); + return 0; bad_anchor: PyErr_Format(PyExc_ValueError, "bad anchor specified: %s", anchor); - return NULL; + return 1; +} + +static PyObject * +font_getsize(FontObject *self, PyObject *args) { + int width, height, x_offset, y_offset; + int load_flags; /* FreeType load_flags parameter */ + int error; + GlyphInfo *glyph_info = NULL; /* computed text layout */ + size_t count; /* glyph_info length */ + int horizontal_dir; /* is primary axis horizontal? */ + int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ + int color = 0; /* is FT_LOAD_COLOR enabled? */ + const char *mode = NULL; + const char *dir = NULL; + const char *lang = NULL; + const char *anchor = NULL; + PyObject *features = Py_None; + PyObject *string; + + /* calculate size and bearing for a given string */ + + if (!PyArg_ParseTuple( + args, "O|zzOzz:getsize", &string, &mode, &dir, &features, &lang, &anchor)) { + return NULL; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + mask = mode && strcmp(mode, "1") == 0; + color = mode && strcmp(mode, "RGBA") == 0; + + count = text_layout(string, self, dir, features, lang, &glyph_info, mask, color); + if (PyErr_Occurred()) { + return NULL; + } + + load_flags = FT_LOAD_DEFAULT; + if (mask) { + load_flags |= FT_LOAD_TARGET_MONO; + } + if (color) { + load_flags |= FT_LOAD_COLOR; + } + + error = bounding_box_and_anchors(self->face, anchor, horizontal_dir, glyph_info, count, load_flags, &width, &height, &x_offset, &y_offset); + if (glyph_info) { + PyMem_Free(glyph_info); + glyph_info = NULL; + } + if (error) { + return NULL; + } + + return Py_BuildValue( + "(ii)(ii)", + width, + height, + x_offset, + y_offset); } static PyObject * @@ -785,6 +798,7 @@ font_render(FontObject *self, PyObject *args) { unsigned int bitmap_y; /* glyph bitmap y index */ unsigned char *source; /* glyph bitmap source buffer */ unsigned char convert_scale; /* scale factor for non-8bpp bitmaps */ + PyObject *image; Imaging im; Py_ssize_t id; int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */ @@ -795,27 +809,34 @@ font_render(FontObject *self, PyObject *args) { const char *mode = NULL; const char *dir = NULL; const char *lang = NULL; + const char *anchor = NULL; PyObject *features = Py_None; PyObject *string; + PyObject *fill; float x_start = 0; float y_start = 0; + int width, height, x_offset, y_offset; + int horizontal_dir; /* is primary axis horizontal? */ + PyObject *max_image_pixels = Py_None; /* render string into given buffer (the buffer *must* have the right size, or this will crash) */ if (!PyArg_ParseTuple( args, - "On|zzOziLff:render", + "OO|zzOzizLffO:render", &string, - &id, + &fill, &mode, &dir, &features, &lang, &stroke_width, + &anchor, &foreground_ink_long, &x_start, - &y_start)) { + &y_start, + &max_image_pixels)) { return NULL; } @@ -841,8 +862,41 @@ font_render(FontObject *self, PyObject *args) { if (PyErr_Occurred()) { return NULL; } - if (count == 0) { - Py_RETURN_NONE; + + load_flags = stroke_width ? FT_LOAD_NO_BITMAP : FT_LOAD_DEFAULT; + if (mask) { + load_flags |= FT_LOAD_TARGET_MONO; + } + if (color) { + load_flags |= FT_LOAD_COLOR; + } + + horizontal_dir = dir && strcmp(dir, "ttb") == 0 ? 0 : 1; + + error = bounding_box_and_anchors(self->face, anchor, horizontal_dir, glyph_info, count, load_flags, &width, &height, &x_offset, &y_offset); + if (error) { + PyMem_Del(glyph_info); + return NULL; + } + + width += stroke_width * 2 + ceil(x_start); + height += stroke_width * 2 + ceil(y_start); + if (max_image_pixels != Py_None) { + if (width * height > PyLong_AsLong(max_image_pixels) * 2) { + PyMem_Del(glyph_info); + return Py_BuildValue("O(ii)(ii)", Py_None, width, height, 0, 0); + } + } + + image = PyObject_CallFunction(fill, "s(ii)", strcmp(mode, "RGBA") == 0 ? "RGBA" : "L", width, height); + id = PyLong_AsSsize_t(PyObject_GetAttrString(image, "id")); + im = (Imaging)id; + + x_offset -= stroke_width; + y_offset -= stroke_width; + if (count == 0 || width == 0 || height == 0) { + PyMem_Del(glyph_info); + return Py_BuildValue("O(ii)(ii)", image, width, height, x_offset, y_offset); } if (stroke_width) { @@ -859,15 +913,6 @@ font_render(FontObject *self, PyObject *args) { 0); } - im = (Imaging)id; - load_flags = stroke_width ? FT_LOAD_NO_BITMAP : FT_LOAD_DEFAULT; - if (mask) { - load_flags |= FT_LOAD_TARGET_MONO; - } - if (color) { - load_flags |= FT_LOAD_COLOR; - } - /* * calculate x_min and y_max * must match font_getsize or there may be clipping! @@ -1064,7 +1109,7 @@ font_render(FontObject *self, PyObject *args) { } FT_Stroker_Done(stroker); PyMem_Del(glyph_info); - Py_RETURN_NONE; + return Py_BuildValue("O(ii)(ii)", image, width, height, x_offset, y_offset); glyph_error: if (stroker != NULL) { From 4dcca33d3099e29110b24cc507e8f0799e1d1ab7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 19:06:25 +1000 Subject: [PATCH 37/40] Removed unused arguments --- src/_imagingft.c | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/_imagingft.c b/src/_imagingft.c index 80f862bb7..8fc1fa7d0 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -254,9 +254,7 @@ text_layout_raqm( const char *dir, PyObject *features, const char *lang, - GlyphInfo **glyph_info, - int mask, - int color) { + GlyphInfo **glyph_info) { size_t i = 0, count = 0, start = 0; raqm_t *rq; raqm_glyph_t *glyphs = NULL; @@ -493,7 +491,7 @@ text_layout( #ifdef HAVE_RAQM if (have_raqm && self->layout_engine == LAYOUT_RAQM) { count = text_layout_raqm( - string, self, dir, features, lang, glyph_info, mask, color); + string, self, dir, features, lang, glyph_info); } else #endif { From 16d82c2dfd473836e7903165917584bf938b0345 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 19:37:54 +1000 Subject: [PATCH 38/40] Improved coverage --- Tests/test_imagefont.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 7ea485a55..4a40d1d1d 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -463,6 +463,11 @@ def test_default_font(): assert_image_equal_tofile(im, "Tests/images/default_font.png") +@pytest.mark.parametrize("mode", (None, "1", "RGBA")) +def test_getbbox(font, mode): + assert (0, 4, 12, 16) == font.getbbox("A", mode) + + def test_getbbox_empty(font): # issue #2614, should not crash. assert (0, 0, 0, 0) == font.getbbox("") From 1756df461561234184611dfe8b42f1b7f33de1f0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 10 Jun 2023 20:24:34 +1000 Subject: [PATCH 39/40] Removed unused private method --- src/PIL/ImageFont.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index ea4549cf5..abcb88520 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -226,10 +226,6 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def _multiline_split(self, text): - split_character = "\n" if isinstance(text, str) else b"\n" - return text.split(split_character) - def getname(self): """ :return: A tuple of the font family (e.g. Helvetica) and the font style From 5a0fb8ec127b7b23d13d22d7a8ade5505835435f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 11 Jun 2023 00:05:47 +0300 Subject: [PATCH 40/40] Add Debian 12 Bookworm --- .github/workflows/test-docker.yml | 1 + docs/installation.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 4f01abe44..3bcb8cfbc 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -39,6 +39,7 @@ jobs: centos-stream-8-amd64, centos-stream-9-amd64, debian-11-bullseye-x86, + debian-12-bookworm-x86, fedora-37-amd64, fedora-38-amd64, gentoo, diff --git a/docs/installation.rst b/docs/installation.rst index ad27b67ee..ac54b037d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -448,6 +448,8 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Debian 11 Bullseye | 3.9 | x86 | +----------------------------------+----------------------------+---------------------+ +| Debian 12 Bookworm | 3.11 | x86 | ++----------------------------------+----------------------------+---------------------+ | Fedora 37 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ | Fedora 38 | 3.11 | x86-64 |