From 2245df0ac30976960781f291bc45a2ad56cee064 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 6 Apr 2024 20:59:06 +1100 Subject: [PATCH 01/91] Only preserve IPTC_NAA_CHUNK tag if type is BYTE or UNDEFINED --- Tests/test_file_tiff.py | 13 +++++++++++++ src/PIL/TiffImagePlugin.py | 30 ++++++++++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a..0bc1e2d0e 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -621,6 +621,19 @@ class TestFileTiff: assert_image_equal_tofile(im, tmpfile) + def test_iptc(self, tmp_path: Path) -> None: + # Do not preserve IPTC_NAA_CHUNK by default if type is LONG + outfile = str(tmp_path / "temp.tif") + im = hopper() + ifd = TiffImagePlugin.ImageFileDirectory_v2() + ifd[33723] = 1 + ifd.tagtype[33723] = 4 + im.tag_v2 = ifd + im.save(outfile) + + with Image.open(outfile) as im: + assert 33723 not in im.tag_v2 + def test_rowsperstrip(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") im = hopper() diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 8bfcd2907..c3683efb3 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1653,6 +1653,16 @@ def _save(im, fp, filename): except Exception: pass # might not be an IFD. Might not have populated type + legacy_ifd = {} + if hasattr(im, "tag"): + legacy_ifd = im.tag.to_v2() + + supplied_tags = {**legacy_ifd, **getattr(im, "tag_v2", {})} + if SAMPLEFORMAT in supplied_tags: + # SAMPLEFORMAT is determined by the image format and should not be copied + # from legacy_ifd. + del supplied_tags[SAMPLEFORMAT] + # additions written by Greg Couch, gregc@cgl.ucsf.edu # inspired by image-sig posting from Kevin Cazabon, kcazabon@home.com if hasattr(im, "tag_v2"): @@ -1666,8 +1676,14 @@ def _save(im, fp, filename): XMP, ): if key in im.tag_v2: - ifd[key] = im.tag_v2[key] - ifd.tagtype[key] = im.tag_v2.tagtype[key] + if key == IPTC_NAA_CHUNK and im.tag_v2.tagtype[key] not in ( + TiffTags.BYTE, + TiffTags.UNDEFINED, + ): + del supplied_tags[key] + else: + ifd[key] = im.tag_v2[key] + ifd.tagtype[key] = im.tag_v2.tagtype[key] # preserve ICC profile (should also work when saving other formats # which support profiles as TIFF) -- 2008-06-06 Florian Hoech @@ -1807,16 +1823,6 @@ def _save(im, fp, filename): # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. - legacy_ifd = {} - if hasattr(im, "tag"): - legacy_ifd = im.tag.to_v2() - - # SAMPLEFORMAT is determined by the image format and should not be copied - # from legacy_ifd. - supplied_tags = {**getattr(im, "tag_v2", {}), **legacy_ifd} - if SAMPLEFORMAT in supplied_tags: - del supplied_tags[SAMPLEFORMAT] - for tag, value in itertools.chain(ifd.items(), supplied_tags.items()): # Libtiff can only process certain core items without adding # them to the custom dictionary. From 34b3cb519c3235366c3881f4e8a59ef3bf2c22b2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 10 Apr 2024 23:37:38 +1000 Subject: [PATCH 02/91] Converted example images to WebP --- docs/deprecations.rst | 2 +- docs/example/anchors.png | Bin 9726 -> 0 bytes docs/example/anchors.py | 2 +- docs/example/anchors.webp | Bin 0 -> 6350 bytes docs/example/image_thumbnail.png | Bin 19241 -> 0 bytes docs/example/image_thumbnail.webp | Bin 0 -> 2560 bytes docs/example/imageops_contain.png | Bin 19241 -> 0 bytes docs/example/imageops_contain.webp | Bin 0 -> 2560 bytes docs/example/imageops_cover.png | Bin 38843 -> 0 bytes docs/example/imageops_cover.webp | Bin 0 -> 4164 bytes docs/example/imageops_fit.png | Bin 28146 -> 0 bytes docs/example/imageops_fit.webp | Bin 0 -> 3234 bytes docs/example/imageops_pad.png | Bin 19499 -> 0 bytes docs/example/imageops_pad.webp | Bin 0 -> 2566 bytes docs/example/size_vs_bbox.png | Bin 12934 -> 0 bytes docs/example/size_vs_bbox.webp | Bin 0 -> 4948 bytes docs/handbook/text-anchors.rst | 2 +- docs/handbook/tutorial.rst | 30 ++++++++++++++--------------- docs/reference/ImageOps.rst | 30 ++++++++++++++--------------- docs/releasenotes/9.2.0.rst | 2 +- 20 files changed, 34 insertions(+), 34 deletions(-) delete mode 100644 docs/example/anchors.png create mode 100644 docs/example/anchors.webp delete mode 100644 docs/example/image_thumbnail.png create mode 100644 docs/example/image_thumbnail.webp delete mode 100644 docs/example/imageops_contain.png create mode 100644 docs/example/imageops_contain.webp delete mode 100644 docs/example/imageops_cover.png create mode 100644 docs/example/imageops_cover.webp delete mode 100644 docs/example/imageops_fit.png create mode 100644 docs/example/imageops_fit.webp delete mode 100644 docs/example/imageops_pad.png create mode 100644 docs/example/imageops_pad.webp delete mode 100644 docs/example/size_vs_bbox.png create mode 100644 docs/example/size_vs_bbox.webp diff --git a/docs/deprecations.rst b/docs/deprecations.rst index c3d1ba4f0..f530d5741 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -264,7 +264,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. -.. image:: ./example/size_vs_bbox.png +.. image:: ./example/size_vs_bbox.webp :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center diff --git a/docs/example/anchors.png b/docs/example/anchors.png deleted file mode 100644 index 40476b0922b4494b8d154a81bf3788798b078d75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9726 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 Awg3PC diff --git a/docs/example/anchors.py b/docs/example/anchors.py index b5d76b4fe..2ee11103f 100644 --- a/docs/example/anchors.py +++ b/docs/example/anchors.py @@ -26,5 +26,5 @@ if __name__ == "__main__": d.line(((x * 200, y * 100), (x * 200, (y + 1) * 100)), "black", 3) if y != 0: d.line(((x * 200, y * 100), ((x + 1) * 200, y * 100)), "black", 3) - im.save("docs/example/anchors.png") + im.save("docs/example/anchors.webp") im.show() diff --git a/docs/example/anchors.webp b/docs/example/anchors.webp new file mode 100644 index 0000000000000000000000000000000000000000..216b6c235a26b7515e3b756e38bc26b2c16a9c56 GIT binary patch literal 6350 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 OdA)BXvok02N7 zy&fl?X676c+A+nU`yD8d|GL9V6|F=ciCb;byvpZ6*D9?EY}(dg3>Y>USW+&y+R|S> z&NTfWgLdbr5|&Hd-}g!qT#BVg1`+LKhq2;*?s)GAWrWA`27^T8Ff!!Z+C>&Xysw({ zF!g*6Eq8s=yd3E zMMHAHSq2QIys=Qip(o>3u=RDyE!U_n7xHBMHqd+`#aP)X;D$3{KF+z?(T0hkIAcH< zTzfw9W1W4}%%_0U9z~nvk;tj`|D)F^n0e?n<>42`ZPu$820^lU8qYs_h(6o?HHGiiFL= zKI{2M3B;;FwO}l|cO9KSh)AfdFwWM&W$JKYbVn)**e%SVNeuzS4GU+$Bu(-xWd6O@ zbm2^H>bl?$6#fd}17y4amWEnzNUtpkck(<$NE_pgjW2@*L`JImcE;__ot~EuOavIE zTwE+jG=Bci%qZ%|875jF<2+@uiu4(5{VG0C$8sd;{+ znvnEKQn@8G0EUx*0;S_(FK`2i#zn+QL?LL9P@ z?O7h<;9&=H_G=VU{OrV%?l01Fgz#p(Jzx6<4fw0uH zhsq?&T&^3Z>VPt`cm}W8tkA_Vw7ttR#@kXbloPiVc2e{3H{ceZ7$3T>9Y|5wd-5(j z=VLNM>frnKhGHhZvJ-gPo{>nyR_8hwyNMt;y(HCFRheMEPBkb%YP_9wD!MJUP2QT0 zs)98MJnQ%rhssR{OGJ=O(IVANpoc@|W{1RTIv6`JOl5kMCjPvbjo*Y2We3pV@j!8^ zb}LbEGV1aXD+jRfM3g^|5o-q>rwoA2w{}d)04PS-fIO0)QCU@{hfu}gVZJrtbI}D1 z-BUPiiWVZ3rNHD&4tWy1REWkp*}6b2u3&>k9y@bYu`gD%pT_e_CNEBk!TrJ zgH*Y^VR{u)SXD~Yr^Ab6If+7A9pX9G5?9w9Sc;%sYY=b8hSU=kW@S8emNyGkHofss z6$*6CXSrJ+g)nLoD3!PnRB(tq%yV2V9CKD#15is55zh7$m;*;@52)SnG##-RFVN9-zT^}* zJ8{Cpjlkd8)DZ_7ke}DsJU`5t9B)CDfQY}^&%#Aazy|^9?;=+4N!5b9BYB08{$P^m z0l$P;#@=Y-ho3@Fvo+jwRWV@QC2;$6VW0WAiC2#eD>WCes0v|P zpdUC78%M&4RcaiiSdOa(6&GK?W^8%f-s1a!k*QmBli2|=k&b~%kj^=}V_acjVzG?V zy;1EQkKQF+@RW}fT{e}(-SPNv6q#LxUFk7w`n;WmFIAWZYu1h=iqO^Ej~R% zJW6v!L_yfqZcemRSH#hC7JVR6o0+Yjv9&!Sk%q0#bT4-jKyi9Ws;;Xt!F-%*P=M8W zI_gz)TWp)XH62w1Y7%+Z@F@?JnhutUClHk+4gYCM(1&*Z_}$a>4W_ir%0m}DVB#mC;D8EL Q)Yh+1e63w>000000CfdR{r~^~ literal 0 HcmV?d00001 diff --git a/docs/example/image_thumbnail.png b/docs/example/image_thumbnail.png deleted file mode 100644 index 293b05794659734d56726ce7c21310f16773e0b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19241 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 QfB*mh07*qoM6N<$f=X7t3;+NC diff --git a/docs/example/image_thumbnail.webp b/docs/example/image_thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..9780f2852fe434eaaf8e3daf43fff9cca3799c29 GIT binary patch literal 2560 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 QfB*mh07*qoM6N<$f=X7t3;+NC diff --git a/docs/example/imageops_contain.webp b/docs/example/imageops_contain.webp new file mode 100644 index 0000000000000000000000000000000000000000..9780f2852fe434eaaf8e3daf43fff9cca3799c29 GIT binary patch literal 2560 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 Ta2hb800000NkvXXu0mjfV0nW_ diff --git a/docs/example/imageops_cover.webp b/docs/example/imageops_cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..a0b6c10bf2ffc5c6686eba10c7065780313b1c3e GIT binary patch literal 4164 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 O3znKIDw7Iy0000#)g(Cp literal 0 HcmV?d00001 diff --git a/docs/example/imageops_fit.png b/docs/example/imageops_fit.png deleted file mode 100644 index 13a3d5e3febdf3cc465f2bcc45dacfb817a5e7f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28146 zcmV)=K!m@EP)dNklL zcd%t?avz9g=4Xz%_W4)dSNrbi?wP@O0}N*19Z3+NS&&PHxR5B3id9gt6t&U@BUU1U zyAhN`Vu{s?T9P0M28n?m%nUHV0OPxRdfIn&dHejk{+Q2X>W_2ZtLp9

RqO9bLC> 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�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 diff --git a/docs/example/imageops_pad.webp b/docs/example/imageops_pad.webp new file mode 100644 index 0000000000000000000000000000000000000000..0ab63ef42e02a5408d79beba6ea393c4d5fe47c0 GIT binary patch literal 2566 zcmV+h3i<|BMM6+kP&gp+2><}_EdZSXDr5kb06uLpl}4l^B%vx5+bEC? ziDCfJ^$Pz0JO96@p!|sO^wR&E<61D*$p2CPtJy}~Uo*e2KdxTxx^!FARqKC<@bAfg zt$n@zwa+#qUn%?L`tBkA6lw+RZ}nULU<2!i{67tyQ+y+?AwUn#9*DXS`n%XerD4+< zQ|GBD@`~w4cb-`8tjKJTIG>#v(h#tC0HU`tHuSEr5um=c?B99snuwqhCR(fzwAd0y z96j;-Wv!>kNeVL$C!_%uyb1UiXy zj^FbHjQsST>1KDqz^{_-MOLzAlY_i66m zKcK_?1|Rwf001=n(Gq-R#)X%}qx8Eigd-x&&G0vJlJWRO9?Ow;M-Zs~Uf0@?454U* zN|HXmM3zUg$O?9TdB2saWp0&jraQ2 zyM9YgPIT@Y^=S)AETbZC*=K+5cEM!!S3K5%Bpc9{k+W6(i5MpM{67Pa7ykaQd&TsD zD_Rmi9ZJo zPP)~Ucg)k3_f0+0RT#Eyg4WxjnN$iPzOouG&+cF4H3PRr_0jYxeog*WX*(OhI33CN zQgp(h*CICFcfve#+^qhF8$}TfIYn4Ui~p<6#)sKoGg|893UV;D$yx9d{+3=d#gfg< z**#~URZSapb8Ddej+@i3=p4db--wyhiP<1Ng+>NHyQBOZYeKz$Y*7tPa>vLK5!kV_ zpXbTb>@>2+lO*k+zDANfvHid`qoQ>)`^r#+@UnfZZ@gIU4Cw4dYFne!+!i`H<{)Qr z`-s%zp*B2moab{)4V5PC`CRPX*_?y^e|`eJ9cVE8*0c$laytL1h-fz}QztIs$|9I7 z4<&l;OL8Y6WgElZc00>W!y-2onzYX{2e!|}8zp6njQx;ISs zl=VGg)rz)$2GE7FD$MY0@X}tNtzENFxcg*oe99#p=#%I-y0ebp2G)Tg=u|<<6B)sc zyvEJ9S6uDV{j-7o6m7tsSj42?7w{9Y-F)&yfNFChlRS4uhp$zpp zNRgn^YXz>9*dy9d7fO~gObFD451h8zcCJLv=aV+epP@7q{EJD0pmGz0wgC+@YiCbj zwxF+)u37X7H&a;kn9J*g(7qkLFVxLgNgD)NmE?gF>qLElP*d)aP<;!6W+uDG`pqxT z+|e4wQO3pe%f^FSA9ln>zM87ejd1Re`eQRb{1XXoSG+bjqAXqxlLMqKGmxzN#J(AS%$ z`H4eb)Q+`og&|+8H7JQ~sV10(QEWQGYJR3`HciMsU6&$Z#~omu#YRJyRT3sUgo>sJ z#f8o7`jcB3^;_Pr7sv&M@JdMeAU4ePi1Q$AIhCQ2$|o^=*Psu@~|+Bgoq3 z0O(5S7rM72!E@>JCs|z^Mgr9br9R~WMhR%te?zH>jYJySivKXdZc;U8C=(P0dDG8r z0p4cdv0ytUwwQygUW@Q&B`ba#Z^2))rh(Oj=0sYKXq`A!xCAmde z&%Tf0=P(iiaA~RJ@aQ*fg#xDUTy|K6tvHNgUsJZvFKhrveSM&M-*^KHJ3Gt*%Y>$V zhL4ZqLsI4}Y$O=J5f;WUCVv7ZI_e$cq^92s6+3AErIJZteo6Zr+Sk^bn|*lGmq?aj zYshZ=iVk)q5guk5`_)8=7o!;Omcx~&(OPz4*{o-P3jkt$9_Vwm9vEu5`k_;t{H=qP zb>Mb-rgDhKF~NfgD$<4isav)!BIC1d=btt+i`q z4eIg(WC%3=z_Zf|Hy8gk+P+5J1p})EmQ%-DQ||BzCvmb+Np&xEO&q9`3LIYY|Gtk6X}gz$1@`h#_IZxf+QSH zPuHqPPE@El=s?46&UXO>PFfD9L`-YzaB`iakyKLs&nk%c=3?b9e^?*07J%lYp;eDF zaZu;qkSNax=dC8gjkF%4FZA&Q8kY2+7tm%m5j9VkKGRKZfC05Ljs--Rm=@+E%u5d! cs5@GC(gS8efiIaw&ZnBm!e}UV@)loZ1 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 diff --git a/docs/example/size_vs_bbox.webp b/docs/example/size_vs_bbox.webp new file mode 100644 index 0000000000000000000000000000000000000000..391162d2d4ba02f3ebf4d866a7fdf458c5612faf GIT binary patch literal 4948 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 SC^AeeSg-&9000000000w&A&VV literal 0 HcmV?d00001 diff --git a/docs/handbook/text-anchors.rst b/docs/handbook/text-anchors.rst index 3a9572ab2..48de4bc95 100644 --- a/docs/handbook/text-anchors.rst +++ b/docs/handbook/text-anchors.rst @@ -132,7 +132,7 @@ of the two lines. .. comment: Image generated with ../example/anchors.py -.. image:: ../example/anchors.png +.. image:: ../example/anchors.webp :alt: Text anchor examples :align: center diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index 523e2ad74..6cb1e2639 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -278,26 +278,26 @@ choose to resize relative to a given size. from PIL import Image, ImageOps size = (100, 150) - with Image.open("Tests/images/hopper.png") as im: - ImageOps.contain(im, size).save("imageops_contain.png") - ImageOps.cover(im, size).save("imageops_cover.png") - ImageOps.fit(im, size).save("imageops_fit.png") - ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") + with Image.open("Tests/images/hopper.webp") as im: + ImageOps.contain(im, size).save("imageops_contain.webp") + ImageOps.cover(im, size).save("imageops_cover.webp") + ImageOps.fit(im, size).save("imageops_fit.webp") + ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp") # thumbnail() can also be used, # but will modify the image object in place im.thumbnail(size) - im.save("imageops_thumbnail.png") + im.save("image_thumbnail.webp") -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | -+================+===========================================+============================================+==========================================+========================================+========================================+ -|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | ++================+============================================+=============================================+===========================================+=========================================+=========================================+ +|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ .. _color-transforms: diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 051fdcfc9..fcaa3c8f6 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -36,26 +36,26 @@ Resize relative to a given size from PIL import Image, ImageOps size = (100, 150) - with Image.open("Tests/images/hopper.png") as im: - ImageOps.contain(im, size).save("imageops_contain.png") - ImageOps.cover(im, size).save("imageops_cover.png") - ImageOps.fit(im, size).save("imageops_fit.png") - ImageOps.pad(im, size, color="#f00").save("imageops_pad.png") + with Image.open("Tests/images/hopper.webp") as im: + ImageOps.contain(im, size).save("imageops_contain.webp") + ImageOps.cover(im, size).save("imageops_cover.webp") + ImageOps.fit(im, size).save("imageops_fit.webp") + ImageOps.pad(im, size, color="#f00").save("imageops_pad.webp") # thumbnail() can also be used, # but will modify the image object in place im.thumbnail(size) - im.save("imageops_thumbnail.png") + im.save("image_thumbnail.webp") -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | -+================+===========================================+============================================+==========================================+========================================+========================================+ -|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting image | .. image:: ../example/image_thumbnail.png | .. image:: ../example/imageops_contain.png | .. image:: ../example/imageops_cover.png | .. image:: ../example/imageops_fit.png | .. image:: ../example/imageops_pad.png | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ -|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | -+----------------+-------------------------------------------+--------------------------------------------+------------------------------------------+----------------------------------------+----------------------------------------+ ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +| | :py:meth:`~PIL.Image.Image.thumbnail` | :py:meth:`~PIL.ImageOps.contain` | :py:meth:`~PIL.ImageOps.cover` | :py:meth:`~PIL.ImageOps.fit` | :py:meth:`~PIL.ImageOps.pad` | ++================+============================================+=============================================+===========================================+=========================================+=========================================+ +|Given size | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ``(100, 150)`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting image | .. image:: ../example/image_thumbnail.webp | .. image:: ../example/imageops_contain.webp | .. image:: ../example/imageops_cover.webp | .. image:: ../example/imageops_fit.webp | .. image:: ../example/imageops_pad.webp | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ +|Resulting size | ``100×100`` | ``100×100`` | ``150×150`` | ``100×150`` | ``100×150`` | ++----------------+--------------------------------------------+---------------------------------------------+-------------------------------------------+-----------------------------------------+-----------------------------------------+ .. autofunction:: contain .. autofunction:: cover diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index fe29f2e4f..6e0647343 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -98,7 +98,7 @@ Previously, the ``size`` methods returned a ``height`` that included the vertica offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` offset. -.. image:: ../example/size_vs_bbox.png +.. image:: ../example/size_vs_bbox.webp :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. :align: center From 7b9a276c7f2dcba699bb2d9c7530f70bb499fa00 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 13 Apr 2024 13:47:52 +1000 Subject: [PATCH 03/91] Updated libwebp to 1.4.0 --- .github/workflows/wheels-dependencies.sh | 2 +- depends/install_webp.sh | 2 +- winbuild/build_prepare.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..0cf5c58ab 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -33,7 +33,7 @@ if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then else ZLIB_VERSION=1.2.8 fi -LIBWEBP_VERSION=1.3.2 +LIBWEBP_VERSION=1.4.0 BZIP2_VERSION=1.0.8 LIBXCB_VERSION=1.16.1 BROTLI_VERSION=1.1.0 diff --git a/depends/install_webp.sh b/depends/install_webp.sh index 6f867ab37..c47fb35f1 100755 --- a/depends/install_webp.sh +++ b/depends/install_webp.sh @@ -1,7 +1,7 @@ #!/bin/bash # install webp -archive=libwebp-1.3.2 +archive=libwebp-1.4.0 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..7ff645fc9 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -117,7 +117,7 @@ V = { "JPEGTURBO": "3.0.2", "LCMS2": "2.16", "LIBPNG": "1.6.43", - "LIBWEBP": "1.3.2", + "LIBWEBP": "1.4.0", "OPENJPEG": "2.5.2", "TIFF": "4.6.0", "XZ": "5.4.5", From 1af66df732f1842494d4eb8470f81c4c1e94b5b9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 16 Apr 2024 07:13:40 +1000 Subject: [PATCH 04/91] Updated xcb-proto to 1.17.0 --- .github/workflows/wheels-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..2d5e174ce 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -70,7 +70,7 @@ function build { fi build_new_zlib - build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto + build_simple xcb-proto 1.17.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; then build_simple xorgproto 2024.1 https://www.x.org/pub/individual/proto build_simple libXau 1.0.11 https://www.x.org/pub/individual/lib From 712aa994f27aba19209e33be382f1a8c85ade82f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 16 Apr 2024 07:14:04 +1000 Subject: [PATCH 05/91] Updated libxcb to 1.17.0 --- .github/workflows/wheels-dependencies.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 2d5e174ce..e140665fe 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -35,7 +35,7 @@ else fi LIBWEBP_VERSION=1.3.2 BZIP2_VERSION=1.0.8 -LIBXCB_VERSION=1.16.1 +LIBXCB_VERSION=1.17.0 BROTLI_VERSION=1.1.0 if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then From 2c0b2dceba4f72694221f8a1acb2efb16e761047 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 18 Apr 2024 08:33:37 +1000 Subject: [PATCH 06/91] Updated nasm to 2.16.03 --- .appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.appveyor.yml b/.appveyor.yml index 57a8fa5a0..dfa548548 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -32,10 +32,10 @@ install: - curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip - 7z x pillow-test-images.zip -oc:\ - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images -- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip +- curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip - 7z x nasm-win64.zip -oc:\ - choco install ghostscript --version=10.3.0 -- path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% +- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.00.0\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ From 98a0515494bc4cb43f2ede5fd80af82ee8f4fc59 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 22 Apr 2024 08:05:59 +1000 Subject: [PATCH 07/91] Read images as RGB, rather than RGBX --- src/PIL/ImImagePlugin.py | 2 +- src/PIL/TiffImagePlugin.py | 16 ++++++++-------- src/libImaging/Unpack.c | 4 ++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 4613e40b6..6463572f5 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -78,7 +78,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"), } diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 10ac9ea3a..e37140590 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -200,12 +200,12 @@ OPEN_INFO = { (MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"), (II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples (MM, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples - (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGBX", "RGBX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGBX", "RGBXX"), - (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), - (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGBX", "RGBXXX"), + (II, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (MM, 2, (1,), 1, (8, 8, 8, 8), (0,)): ("RGB", "RGBX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8), (0, 0)): ("RGB", "RGBXX"), + (II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), + (MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"), (II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"), (II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"), @@ -224,8 +224,8 @@ OPEN_INFO = { (MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"), - (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16L"), - (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGBX", "RGBX;16B"), + (II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"), + (MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"), (MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"), (II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"), diff --git a/src/libImaging/Unpack.c b/src/libImaging/Unpack.c index a84dc0a6f..f15769c43 100644 --- a/src/libImaging/Unpack.c +++ b/src/libImaging/Unpack.c @@ -1599,10 +1599,14 @@ static struct { {"RGB", "BGR;15", 16, ImagingUnpackBGR15}, {"RGB", "RGB;16", 16, ImagingUnpackRGB16}, {"RGB", "BGR;16", 16, ImagingUnpackBGR16}, + {"RGB", "RGBX;16L", 64, unpackRGBA16L}, + {"RGB", "RGBX;16B", 64, unpackRGBA16B}, {"RGB", "RGB;4B", 16, ImagingUnpackRGB4B}, {"RGB", "BGR;5", 16, ImagingUnpackBGR15}, /* compat */ {"RGB", "RGBX", 32, copy4}, {"RGB", "RGBX;L", 32, unpackRGBAL}, + {"RGB", "RGBXX", 40, copy4skip1}, + {"RGB", "RGBXXX", 48, copy4skip2}, {"RGB", "RGBA;L", 32, unpackRGBAL}, {"RGB", "RGBA;15", 16, ImagingUnpackRGBA15}, {"RGB", "BGRX", 32, ImagingUnpackBGRX}, From 39da704c61b4ae5ec9c4117d34a8eda8f058a18e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 28 Apr 2024 07:10:15 +1000 Subject: [PATCH 08/91] Updated libimagequant to 4.3.1 --- depends/install_imagequant.sh | 2 +- docs/installation/building-from-source.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index 973b4374f..9dd7742ed 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -2,7 +2,7 @@ # install libimagequant archive_name=libimagequant -archive_version=4.3.0 +archive_version=4.3.1 archive=$archive_name-$archive_version diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst index 961312b14..7f7dfa6ff 100644 --- a/docs/installation/building-from-source.rst +++ b/docs/installation/building-from-source.rst @@ -68,7 +68,7 @@ Many of Pillow's features require external libraries: * **libimagequant** provides improved color quantization - * Pillow has been tested with libimagequant **2.6-4.3** + * Pillow has been tested with libimagequant **2.6-4.3.1** * Libimagequant is licensed GPLv3, which is more restrictive than the Pillow license, therefore we will not be distributing binaries with libimagequant support enabled. From 5f805c39cccd6ccc5c695a885e274f8a2b4624f9 Mon Sep 17 00:00:00 2001 From: Nulano Date: Mon, 29 Apr 2024 23:19:36 +0200 Subject: [PATCH 09/91] Added type hints for PixelAccess methods and others --- docs/reference/ImageDraw.rst | 18 +----------- docs/reference/PixelAccess.rst | 41 ++------------------------ docs/reference/PyAccess.rst | 1 + src/PIL/Image.py | 53 +++++++++++++++++++++++++--------- src/PIL/ImageDraw.py | 13 +++++++-- src/PIL/ImageGrab.py | 15 +++++++--- src/PIL/PyAccess.py | 48 ++++++++++++++++++------------ 7 files changed, 97 insertions(+), 92 deletions(-) diff --git a/docs/reference/ImageDraw.rst b/docs/reference/ImageDraw.rst index 4ccfacae7..e7339ecbe 100644 --- a/docs/reference/ImageDraw.rst +++ b/docs/reference/ImageDraw.rst @@ -679,23 +679,7 @@ Methods :param hints: An optional list of hints. :returns: A (drawing context, drawing resource factory) tuple. -.. py:method:: floodfill(image, xy, value, border=None, thresh=0) - - .. warning:: This method is experimental. - - Fills a bounded region with a given color. - - :param image: Target image. - :param xy: Seed position (a 2-item coordinate tuple). - :param value: Fill color. - :param border: Optional border value. If given, the region consists of - pixels with a color different from the border color. If not given, - the region consists of pixels having the same color as the seed - pixel. - :param thresh: Optional threshold value which specifies a maximum - tolerable difference of a pixel value from the 'background' in - order for it to be replaced. Useful for filling regions of non- - homogeneous, but similar, colors. +.. autofunction:: PIL.ImageDraw.floodfill .. _BCP 47 language code: https://www.w3.org/International/articles/language-tags/ .. _OpenType docs: https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index 04d6f5dcd..026f488d8 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -44,42 +44,7 @@ Access using negative indexes is also possible. :: ----------------------------- .. class:: PixelAccess + :canonical: PIL.Image.PixelAccess - .. method:: __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). - :param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) - - .. method:: __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). - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. - - .. method:: putpixel(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. In addition to this, RGB and RGBA tuples - are accepted for P and PA images. - - :param xy: The pixel coordinate, given as (x, y). - :param color: The pixel value according to its mode. e.g. tuple (r, g, b) for RGB mode) - - .. method:: getpixel(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). - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. + .. automethod:: PIL.Image.PixelAccess.__getitem__ + .. automethod:: PIL.Image.PixelAccess.__setitem__ diff --git a/docs/reference/PyAccess.rst b/docs/reference/PyAccess.rst index ed58ca3a5..04b2a47ee 100644 --- a/docs/reference/PyAccess.rst +++ b/docs/reference/PyAccess.rst @@ -44,3 +44,4 @@ Access using negative indexes is also possible. :: .. autoclass:: PIL.PyAccess.PyAccess() :members: + :special-members: __getitem__, __setitem__ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a17edfa39..c1b8a2b2f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, SupportsInt, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -482,6 +482,31 @@ def _getscaleoffset(expr): # Implementation wrapper +class PixelAccess(Protocol): + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: + """ + Returns the pixel at x,y. The pixel is returned as a single + value for single band images or a tuple for multi-band images. + + :param xy: The pixel coordinate, given as (x, y). + :returns: a pixel value for single band images, a tuple of + pixel values for multiband images. + """ + raise NotImplementedError() + + def __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None: + """ + 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). + :param color: The pixel value according to its mode, + e.g. tuple (r, g, b) for RGB mode. + """ + raise NotImplementedError() + + class Image: """ This class represents an image object. To create @@ -834,7 +859,7 @@ class Image: msg = "cannot decode image data" raise ValueError(msg) - def load(self): + def load(self) -> PixelAccess | None: """ Allocates storage for the image and loads the pixel data. In normal cases, you don't need to call this method, since the @@ -847,7 +872,7 @@ class Image: operations. See :ref:`file-handling` for more information. :returns: An image access object. - :rtype: :ref:`PixelAccess` or :py:class:`PIL.PyAccess` + :rtype: :py:class:`.PixelAccess` or :py:class:`.PyAccess` """ if self.im is not None and self.palette and self.palette.dirty: # realize palette @@ -876,6 +901,7 @@ class Image: if self.pyaccess: return self.pyaccess return self.im.pixel_access(self.readonly) + return None def verify(self): """ @@ -1485,7 +1511,7 @@ class Image: self._exif._loaded = False self.getexif() - def get_child_images(self): + def get_child_images(self) -> list[ImageFile.ImageFile]: child_images = [] exif = self.getexif() ifds = [] @@ -1509,10 +1535,7 @@ class Image: fp = self.fp thumbnail_offset = ifd.get(513) if thumbnail_offset is not None: - try: - thumbnail_offset += self._exif_offset - except AttributeError: - pass + thumbnail_offset += getattr(self, "_exif_offset", 0) self.fp.seek(thumbnail_offset) data = self.fp.read(ifd.get(514)) fp = io.BytesIO(data) @@ -1578,7 +1601,7 @@ class Image: or "transparency" in self.info ) - def apply_transparency(self): + def apply_transparency(self) -> None: """ If a P mode image has a "transparency" key in the info dictionary, remove the key and instead apply the transparency to the palette. @@ -1590,6 +1613,7 @@ class Image: from . import ImagePalette palette = self.getpalette("RGBA") + assert palette is not None transparency = self.info["transparency"] if isinstance(transparency, bytes): for i, alpha in enumerate(transparency): @@ -1601,7 +1625,9 @@ class Image: del self.info["transparency"] - def getpixel(self, xy): + def getpixel( + self, xy: tuple[SupportsInt, SupportsInt] + ) -> float | tuple[int, ...] | None: """ Returns the pixel value at a given position. @@ -1865,7 +1891,7 @@ class Image: lut = [round(i) for i in lut] return self._new(self.im.point(lut, mode)) - def putalpha(self, alpha): + def putalpha(self, alpha: Image | int) -> None: """ Adds or replaces the alpha layer in this image. If the image does not have an alpha layer, it's converted to "LA" or "RGBA". @@ -1912,6 +1938,7 @@ class Image: alpha = alpha.convert("L") else: # constant alpha + alpha = cast(int, alpha) # see python/typing#1013 try: self.im.fillband(band, alpha) except (AttributeError, ValueError): @@ -1975,7 +2002,7 @@ class Image: self.palette.mode = "RGB" self.load() # install new palette - def putpixel(self, xy, value): + def putpixel(self, xy: tuple[int, int], value: float | tuple[int, ...]) -> None: """ Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for @@ -2015,7 +2042,7 @@ class Image: value = value[:3] value = self.palette.getcolor(value, self) if self.mode == "PA": - value = (value, alpha) + value = (value, alpha) # type: ignore[assignment] return self.im.putpixel(xy, value) def remap_palette(self, dest_map, source_palette=None): diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index d3efe6486..5c179000c 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -898,9 +898,17 @@ def getdraw(im=None, hints=None): return im, handler -def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: +def floodfill( + image: Image.Image, + xy: tuple[int, int], + value: float | tuple[int, ...], + border: float | tuple[int, ...] | None = None, + thresh: float = 0, +) -> None: """ - (experimental) Fills a bounded region with a given color. + .. warning:: This method is experimental. + + Fills a bounded region with a given color. :param image: Target image. :param xy: Seed position (a 2-item coordinate tuple). See @@ -918,6 +926,7 @@ def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: # based on an implementation by Eric S. Raymond # amended by yo1995 @20180806 pixel = image.load() + assert pixel is not None x, y = xy try: background = pixel[x, y] diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 3f3be706d..f52dd301d 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -22,11 +22,17 @@ import shutil import subprocess import sys import tempfile +from typing import Union, cast from . import Image -def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None): +def grab( + bbox: tuple[int, int, int, int] | None = None, + include_layered_windows: bool = False, + all_screens: bool = False, + xdisplay: str | None = None, +) -> Image.Image: if xdisplay is None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") @@ -36,7 +42,7 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N left, top, right, bottom = bbox args += ["-R", f"{left},{top},{right-left},{bottom-top}"] subprocess.call(args + ["-x", filepath]) - im = Image.open(filepath) + im: Image.Image = Image.open(filepath) im.load() os.unlink(filepath) if bbox: @@ -63,6 +69,7 @@ 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 + xdisplay = cast(Union[str, None], xdisplay) # type: ignore[redundant-cast, unused-ignore] try: if not Image.core.HAVE_XCB: msg = "Pillow was built without XCB support" @@ -77,7 +84,7 @@ def grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=N fh, filepath = tempfile.mkstemp(".png") os.close(fh) subprocess.call(["gnome-screenshot", "-f", filepath]) - im = Image.open(filepath) + im: Image.Image = Image.open(filepath) im.load() os.unlink(filepath) if bbox: @@ -94,7 +101,7 @@ 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) diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 2c831913d..af9595666 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -22,6 +22,7 @@ from __future__ import annotations import logging import sys +from typing import TYPE_CHECKING from ._deprecate import deprecate @@ -50,7 +51,7 @@ logger = logging.getLogger(__name__) class PyAccess: - def __init__(self, img, readonly=False): + def __init__(self, img: Image.Image, readonly: bool = False) -> None: deprecate("PyAccess", 11) vals = dict(img.im.unsafe_ptrs) self.readonly = readonly @@ -70,14 +71,15 @@ class PyAccess: # logger.debug("%s", vals) self._post_init() - def _post_init(self): + def _post_init(self) -> None: pass - def __setitem__(self, xy, color): + def __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None: """ 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 + multi-band images. In addition to this, RGB and RGBA tuples + are accepted for P and PA images. :param xy: The pixel coordinate, given as (x, y). See :ref:`coordinate-system`. @@ -104,11 +106,11 @@ class PyAccess: color = color[:3] color = self._palette.getcolor(color, self._img) if self._im.mode == "PA": - color = (color, alpha) + color = (color, alpha) # type: ignore[assignment] return self.set_pixel(x, y, color) - def __getitem__(self, xy): + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: """ Returns the pixel at x,y. The pixel is returned as a single value for single band images or a tuple for multiple band @@ -130,13 +132,19 @@ class PyAccess: putpixel = __setitem__ getpixel = __getitem__ - def check_xy(self, xy): + def check_xy(self, xy: tuple[int, int]) -> tuple[int, int]: (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 + def get_pixel(self, x: int, y: int) -> float | tuple[int, ...]: + raise NotImplementedError() + + def set_pixel(self, x: int, y: int, color: float | tuple[int, ...]) -> None: + raise NotImplementedError() + class _PyAccess32_2(PyAccess): """PA, LA, stored in first and last bytes of a 32 bit word""" @@ -144,7 +152,7 @@ class _PyAccess32_2(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.a @@ -161,7 +169,7 @@ class _PyAccess32_3(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.g, pixel.b @@ -180,7 +188,7 @@ class _PyAccess32_4(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_RGBA **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> tuple[int, int, int, int]: pixel = self.pixels[y][x] return pixel.r, pixel.g, pixel.b, pixel.a @@ -199,7 +207,7 @@ class _PyAccess8(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = self.image8 - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -217,7 +225,7 @@ class _PyAccessI16_N(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("unsigned short **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -235,7 +243,7 @@ class _PyAccessI16_L(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: pixel = self.pixels[y][x] return pixel.l + pixel.r * 256 @@ -256,7 +264,7 @@ class _PyAccessI16_B(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("struct Pixel_I16 **", self.image) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: pixel = self.pixels[y][x] return pixel.l * 256 + pixel.r @@ -277,7 +285,7 @@ class _PyAccessI32_N(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = self.image32 - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> int: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -296,7 +304,7 @@ class _PyAccessI32_Swap(PyAccess): 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): + def get_pixel(self, x: int, y: int) -> int: return self.reverse(self.pixels[y][x]) def set_pixel(self, x, y, color): @@ -309,7 +317,7 @@ class _PyAccessF(PyAccess): def _post_init(self, *args, **kwargs): self.pixels = ffi.cast("float **", self.image32) - def get_pixel(self, x, y): + def get_pixel(self, x: int, y: int) -> float: return self.pixels[y][x] def set_pixel(self, x, y, color): @@ -357,9 +365,13 @@ else: mode_map["I;32B"] = _PyAccessI32_N -def new(img, readonly=False): +def new(img: Image.Image, readonly: bool = False) -> PyAccess | None: 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) + + +if TYPE_CHECKING: + from . import Image From 74b87ae7486e475c032044a648c33605514844ed Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 30 Apr 2024 16:32:29 +0200 Subject: [PATCH 10/91] Move import to top of file --- src/PIL/PyAccess.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index af9595666..cc8e2e359 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -49,6 +49,9 @@ except ImportError as ex: logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from . import Image + class PyAccess: def __init__(self, img: Image.Image, readonly: bool = False) -> None: @@ -371,7 +374,3 @@ def new(img: Image.Image, readonly: bool = False) -> PyAccess | None: logger.debug("PyAccess Not Implemented: %s", img.mode) return None return access_type(img, readonly) - - -if TYPE_CHECKING: - from . import Image From c2cb9445141476283ff0a94be06ad70d4422e535 Mon Sep 17 00:00:00 2001 From: Nulano Date: Tue, 30 Apr 2024 16:32:44 +0200 Subject: [PATCH 11/91] Ignore incorrect mypy warning --- src/PIL/ImageGrab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index f52dd301d..f16e95544 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -84,7 +84,7 @@ def grab( fh, filepath = tempfile.mkstemp(".png") os.close(fh) subprocess.call(["gnome-screenshot", "-f", filepath]) - im: Image.Image = Image.open(filepath) + im: Image.Image = Image.open(filepath) # type: ignore[no-redef, unused-ignore] im.load() os.unlink(filepath) if bbox: From c92f59d758e0a1e308b148f31dd3b7f3f68f94b4 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Tue, 7 May 2024 14:30:34 +0200 Subject: [PATCH 12/91] Add various type annotations --- src/PIL/Image.py | 58 +++++++++++++++++++++++++++++------------- src/PIL/ImageDraw.py | 19 +++++++------- src/PIL/ImageFont.py | 31 ++++++++++++---------- src/PIL/_imagingft.pyi | 37 ++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 41 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 2184ef8ea..f81e95695 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast, overload # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -481,6 +481,8 @@ def _getscaleoffset(expr): # -------------------------------------------------------------------- # Implementation wrapper +class _GetDataTransform(Protocol): + def getdata(self) -> tuple[Transform, Sequence[int]]: ... class Image: """ @@ -1687,7 +1689,7 @@ class Image: return self.im.entropy(extrema) return self.im.entropy() - def paste(self, im, box=None, mask=None) -> None: + def paste(self, im: Image | str | int | tuple[int, ...], box: tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -2122,7 +2124,7 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: + def resize(self, size: tuple[int, int], resample: Resampling | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None) -> Image: """ Returns a resized copy of this image. @@ -2228,7 +2230,7 @@ class Image: return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor, box=None): + def reduce(self, factor: int | tuple[int, int], box: tuple[int, int, int, int] | None = None) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -2263,13 +2265,13 @@ class Image: def rotate( self, - angle, - resample=Resampling.NEAREST, - expand=0, - center=None, - translate=None, - fillcolor=None, - ): + angle: float, + resample: Resampling = Resampling.NEAREST, + expand: bool = False, + center: tuple[int, int] | None = None, + translate: tuple[int, int] | None = None, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: """ Returns a rotated copy of this image. This method returns a copy of this image, rotated the given number of degrees counter @@ -2576,7 +2578,7 @@ class Image: """ return 0 - def thumbnail(self, size, resample=Resampling.BICUBIC, reducing_gap=2.0): + def thumbnail(self, size: tuple[int, int], resample: Resampling = Resampling.BICUBIC, reducing_gap: float = 2.0) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than @@ -2664,14 +2666,34 @@ class Image: # FIXME: the different transform methods need further explanation # instead of bloating the method docs, add a separate chapter. + @overload def transform( self, - size, - method, - data=None, - resample=Resampling.NEAREST, - fill=1, - fillcolor=None, + size: tuple[int, int], + method: Transform | ImageTransformHandler, + data: Sequence[int], + resample: Resampling = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: ... + @overload + def transform( + self, + size: tuple[int, int], + method: _GetDataTransform, + data: None = None, + resample: Resampling = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: ... + def transform( + self, + size: tuple[int, int], + method: Transform | ImageTransformHandler | _GetDataTransform, + data: Sequence[int] | None = None, + resample: Resampling = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: """ Transforms this image. This method creates a new image with the diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index d3efe6486..579489fde 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,10 +34,11 @@ from __future__ import annotations import math import numbers import struct -from typing import Sequence, cast +from typing import AnyStr, Sequence, cast from . import Image, ImageColor from ._typing import Coords +from .ImageFont import FreeTypeFont, ImageFont """ A simple 2D drawing interface for PIL images. @@ -92,7 +93,7 @@ class ImageDraw: self.fontmode = "L" # aliasing is okay for other modes self.fill = False - def getfont(self): + def getfont(self) -> FreeTypeFont | ImageFont: """ Get the current default font. @@ -450,12 +451,12 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text) -> bool: + def _multiline_check(self, text: str | bytes) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text) -> list[str | bytes]: + def _multiline_split(self, text: AnyStr) -> list[AnyStr]: split_character = "\n" if isinstance(text, str) else b"\n" return text.split(split_character) @@ -469,7 +470,7 @@ class ImageDraw: def text( self, - xy, + xy: tuple[int, int], text, fill=None, font=None, @@ -591,7 +592,7 @@ class ImageDraw: def multiline_text( self, - xy, + xy: tuple[int, int], text, fill=None, font=None, @@ -678,15 +679,15 @@ class ImageDraw: def textlength( self, - text, - font=None, + text: str, + font: FreeTypeFont | ImageFont | None = None, direction=None, features=None, language=None, embedded_color=False, *, font_size=None, - ): + ) -> float: """Get the length of a given string, in pixels with 1/64 precision.""" if self._multiline_check(text): msg = "can't measure length of multiline text" diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 256c581df..536ee5fe6 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,12 +33,15 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from typing import BinaryIO +from typing import TYPE_CHECKING, BinaryIO from . import Image from ._typing import StrOrBytesPath from ._util import is_directory, is_path +if TYPE_CHECKING: + from _imagingft import Font + class Layout(IntEnum): BASIC = 0 @@ -56,7 +59,7 @@ except ImportError as ex: core = DeferredError.new(ex) -def _string_length_check(text): +def _string_length_check(text: str | bytes) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) @@ -81,7 +84,9 @@ def _string_length_check(text): class ImageFont: """PIL font wrapper""" - def _load_pilfont(self, filename): + font: Font + + def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: image = None for ext in (".png", ".gif", ".pbm"): @@ -153,7 +158,7 @@ class ImageFont: 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, *args: object, **kwargs: object) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -171,7 +176,7 @@ class ImageFont: width, height = self.font.getsize(text) return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength(self, text: str, *args: object, **kwargs: object) -> int: """ Returns length (in pixels) of given text. This is the amount by which following text should be offset. @@ -254,7 +259,7 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def getname(self): + def getname(self) -> tuple[str, str]: """ :return: A tuple of the font family (e.g. Helvetica) and the font style (e.g. Bold) @@ -269,7 +274,7 @@ 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, mode="", direction=None, features=None, language=None) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -343,14 +348,14 @@ class FreeTypeFont: def getbbox( self, - text, + text: str, mode="", direction=None, features=None, language=None, stroke_width=0, anchor=None, - ): + ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text relative to given anchor when rendered in font with provided direction, features, and language. @@ -725,7 +730,7 @@ class TransposedFont: 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. @@ -739,7 +744,7 @@ def load(filename): return f -def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): +def truetype(font: StrOrBytesPath | BinaryIO | None = None, 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. @@ -800,7 +805,7 @@ def truetype(font=None, size=10, index=0, encoding="", layout_engine=None): :exception ValueError: If the font size is not greater than zero. """ - def freetype(font): + def freetype(font: StrOrBytesPath | BinaryIO | None) -> FreeTypeFont: return FreeTypeFont(font, size, index, encoding, layout_engine) try: @@ -850,7 +855,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. diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index e27843e53..2c2ea9a54 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -1,3 +1,38 @@ -from typing import Any +from typing import Any, TypedDict + +class _Axis(TypedDict): + minimum: int | None + default: int | None + maximum: int | None + name: str | None + + +class Font: + @property + def family(self) -> str | None: ... + @property + def style(self) -> str | None: ... + @property + def ascent(self) -> int: ... + @property + def descent(self) -> int: ... + @property + def height(self) -> int: ... + @property + def x_ppem(self) -> int: ... + @property + def y_ppem(self) -> int: ... + @property + def glyphs(self) -> int: ... + + def render(self, string: str, fill, mode = ..., dir = ..., features = ..., lang = ..., stroke_width = ..., anchor = ..., foreground_ink_long = ..., x_start = ..., y_start = ..., /) -> tuple[Any, tuple[int, int]]: ... + def getsize(self, string: str, mode = ..., dir = ..., features = ..., lang = ..., anchor = ..., /) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength(self, string: str, mode = ..., dir = ..., features = ..., lang = ..., /) -> int: ... + def getvarnames(self) -> list[str]: ... + def getvaraxes(self) -> list[_Axis]: ... + def setvarname(self, instance_index: int, /) -> None: ... + def setvaraxes(self, axes: list[float], /) -> None: ... + +def getfont(filename: str | bytes | bytearray, size, index = ..., encoding = ..., font_bytes = ..., layout_engine = ...) -> Font: ... def __getattr__(name: str) -> Any: ... From 1aa3886ed76b3f8fc60d604a34dffe573b491c20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 May 2024 12:33:59 +0000 Subject: [PATCH 13/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/Image.py | 30 ++++++++++++++++++++++++++---- src/PIL/ImageFont.py | 16 +++++++++++++--- src/PIL/_imagingft.pyi | 36 +++++++++++++++++++++++++++++------- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f81e95695..9f55ea924 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -481,9 +481,11 @@ def _getscaleoffset(expr): # -------------------------------------------------------------------- # Implementation wrapper + class _GetDataTransform(Protocol): def getdata(self) -> tuple[Transform, Sequence[int]]: ... + class Image: """ This class represents an image object. To create @@ -1689,7 +1691,12 @@ class Image: return self.im.entropy(extrema) return self.im.entropy() - def paste(self, im: Image | str | int | tuple[int, ...], box: tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None) -> None: + def paste( + self, + im: Image | str | int | tuple[int, ...], + box: tuple[int, int, int, int] | tuple[int, int] | None = None, + mask: Image | None = None, + ) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -2124,7 +2131,13 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size: tuple[int, int], resample: Resampling | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None) -> Image: + def resize( + self, + size: tuple[int, int], + resample: Resampling | None = None, + box: tuple[float, float, float, float] | None = None, + reducing_gap: float | None = None, + ) -> Image: """ Returns a resized copy of this image. @@ -2230,7 +2243,11 @@ class Image: return self._new(self.im.resize(size, resample, box)) - def reduce(self, factor: int | tuple[int, int], box: tuple[int, int, int, int] | None = None) -> Image: + def reduce( + self, + factor: int | tuple[int, int], + box: tuple[int, int, int, int] | None = None, + ) -> Image: """ Returns a copy of the image reduced ``factor`` times. If the size of the image is not dividable by ``factor``, @@ -2578,7 +2595,12 @@ class Image: """ return 0 - def thumbnail(self, size: tuple[int, int], resample: Resampling = Resampling.BICUBIC, reducing_gap: float = 2.0) -> None: + def thumbnail( + self, + size: tuple[int, int], + resample: Resampling = Resampling.BICUBIC, + reducing_gap: float = 2.0, + ) -> None: """ Make this image into a thumbnail. This method modifies the image to contain a thumbnail version of itself, no larger than diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 536ee5fe6..fb7e1d8b6 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -158,7 +158,9 @@ class ImageFont: Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) - def getbbox(self, text: str, *args: object, **kwargs: object) -> tuple[int, int, int, int]: + def getbbox( + self, text: str, *args: object, **kwargs: object + ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -274,7 +276,9 @@ class FreeTypeFont: """ return self.font.ascent, self.font.descent - def getlength(self, text: str, mode="", direction=None, features=None, language=None) -> float: + def getlength( + self, text: str, mode="", direction=None, features=None, language=None + ) -> float: """ Returns length (in pixels with 1/64 precision) of given text when rendered in font with provided direction, features, and language. @@ -744,7 +748,13 @@ def load(filename: str) -> ImageFont: return f -def truetype(font: StrOrBytesPath | BinaryIO | None = None, size: float = 10, index: int = 0, encoding: str = "", layout_engine: Layout | None = None) -> FreeTypeFont: +def truetype( + font: StrOrBytesPath | BinaryIO | None = None, + 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. diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 2c2ea9a54..987e7fd6f 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -6,7 +6,6 @@ class _Axis(TypedDict): maximum: int | None name: str | None - class Font: @property def family(self) -> str | None: ... @@ -24,15 +23,38 @@ class Font: def y_ppem(self) -> int: ... @property def glyphs(self) -> int: ... - - def render(self, string: str, fill, mode = ..., dir = ..., features = ..., lang = ..., stroke_width = ..., anchor = ..., foreground_ink_long = ..., x_start = ..., y_start = ..., /) -> tuple[Any, tuple[int, int]]: ... - def getsize(self, string: str, mode = ..., dir = ..., features = ..., lang = ..., anchor = ..., /) -> tuple[tuple[int, int], tuple[int, int]]: ... - def getlength(self, string: str, mode = ..., dir = ..., features = ..., lang = ..., /) -> int: ... + def render( + self, + string: str, + fill, + mode=..., + dir=..., + features=..., + lang=..., + stroke_width=..., + anchor=..., + foreground_ink_long=..., + x_start=..., + y_start=..., + /, + ) -> tuple[Any, tuple[int, int]]: ... + def getsize( + self, string: str, mode=..., dir=..., features=..., lang=..., anchor=..., / + ) -> tuple[tuple[int, int], tuple[int, int]]: ... + def getlength( + self, string: str, mode=..., dir=..., features=..., lang=..., / + ) -> int: ... def getvarnames(self) -> list[str]: ... def getvaraxes(self) -> list[_Axis]: ... def setvarname(self, instance_index: int, /) -> None: ... def setvaraxes(self, axes: list[float], /) -> None: ... -def getfont(filename: str | bytes | bytearray, size, index = ..., encoding = ..., font_bytes = ..., layout_engine = ...) -> Font: ... - +def getfont( + filename: str | bytes | bytearray, + size, + index=..., + encoding=..., + font_bytes=..., + layout_engine=..., +) -> Font: ... def __getattr__(name: str) -> Any: ... From d44e9fccb16c63005fbffce06c16a0afc2b26667 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Tue, 7 May 2024 14:53:26 +0200 Subject: [PATCH 14/91] Various fixes --- src/PIL/Image.py | 46 ++++++++++++++++++++++++++++---------------- src/PIL/ImageFont.py | 2 +- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9f55ea924..f6f070fee 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -483,7 +483,7 @@ def _getscaleoffset(expr): class _GetDataTransform(Protocol): - def getdata(self) -> tuple[Transform, Sequence[int]]: ... + def getdata(self) -> tuple[Transform, Sequence[float]]: ... class Image: @@ -2134,7 +2134,7 @@ class Image: def resize( self, size: tuple[int, int], - resample: Resampling | None = None, + resample: int | None = None, box: tuple[float, float, float, float] | None = None, reducing_gap: float | None = None, ) -> Image: @@ -2202,13 +2202,13 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - size = tuple(size) + size = cast(tuple[int, int], tuple(size)) self.load() if box is None: box = (0, 0) + self.size else: - box = tuple(box) + box = cast(tuple[float, float, float, float], tuple(box)) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2266,7 +2266,7 @@ class Image: if box is None: box = (0, 0) + self.size else: - box = tuple(box) + box = cast(tuple[int, int, int, int], tuple(box)) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() @@ -2283,7 +2283,7 @@ class Image: def rotate( self, angle: float, - resample: Resampling = Resampling.NEAREST, + resample: int = Resampling.NEAREST, expand: bool = False, center: tuple[int, int] | None = None, translate: tuple[int, int] | None = None, @@ -2598,7 +2598,7 @@ class Image: def thumbnail( self, size: tuple[int, int], - resample: Resampling = Resampling.BICUBIC, + resample: int = Resampling.BICUBIC, reducing_gap: float = 2.0, ) -> None: """ @@ -2661,20 +2661,22 @@ class Image: box = None if reducing_gap is not None: - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + size = preserved_size - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) + res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) # type: ignore[arg-type] if res is not None: box = res[1] if box is None: self.load() # load() may have changed the size of the image - size = preserve_aspect_ratio() - if size is None: + preserved_size = preserve_aspect_ratio() + if preserved_size is None: return + size = preserved_size if self.size != size: im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) @@ -2693,8 +2695,8 @@ class Image: self, size: tuple[int, int], method: Transform | ImageTransformHandler, - data: Sequence[int], - resample: Resampling = Resampling.NEAREST, + data: Sequence[float], + resample: int = Resampling.NEAREST, fill: int = 1, fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: ... @@ -2704,7 +2706,17 @@ class Image: size: tuple[int, int], method: _GetDataTransform, data: None = None, - resample: Resampling = Resampling.NEAREST, + resample: int = Resampling.NEAREST, + fill: int = 1, + fillcolor: float | tuple[float, ...] | str | None = None, + ) -> Image: ... + @overload + def transform( + self, + size: tuple[int, int], + method: Transform | ImageTransformHandler | _GetDataTransform, + data: Sequence[float] | None = None, + resample: int = Resampling.NEAREST, fill: int = 1, fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: ... @@ -2712,8 +2724,8 @@ class Image: self, size: tuple[int, int], method: Transform | ImageTransformHandler | _GetDataTransform, - data: Sequence[int] | None = None, - resample: Resampling = Resampling.NEAREST, + data: Sequence[float] | None = None, + resample: int = Resampling.NEAREST, fill: int = 1, fillcolor: float | tuple[float, ...] | str | None = None, ) -> Image: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index fb7e1d8b6..a1b722765 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -40,7 +40,7 @@ from ._typing import StrOrBytesPath from ._util import is_directory, is_path if TYPE_CHECKING: - from _imagingft import Font + from ._imagingft import Font class Layout(IntEnum): From d63caf266d2561b1646ed378761332e0855dd73d Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Tue, 7 May 2024 15:59:20 +0200 Subject: [PATCH 15/91] Various fixes --- src/PIL/Image.py | 44 +++++++-------------------------------- src/PIL/ImageDraw.py | 24 ++++++++++----------- src/PIL/ImageFont.py | 13 +++++++++--- src/PIL/ImageTransform.py | 4 ++-- src/PIL/_imaging.pyi | 15 +++++++++++++ 5 files changed, 47 insertions(+), 53 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f6f070fee..9b0c24ec0 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast, overload +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -483,7 +483,9 @@ def _getscaleoffset(expr): class _GetDataTransform(Protocol): - def getdata(self) -> tuple[Transform, Sequence[float]]: ... + def getdata( + self, + ) -> tuple[Transform, Sequence[Any]]: ... class Image: @@ -2690,41 +2692,11 @@ class Image: # FIXME: the different transform methods need further explanation # instead of bloating the method docs, add a separate chapter. - @overload - def transform( - self, - size: tuple[int, int], - method: Transform | ImageTransformHandler, - data: Sequence[float], - resample: int = Resampling.NEAREST, - fill: int = 1, - fillcolor: float | tuple[float, ...] | str | None = None, - ) -> Image: ... - @overload - def transform( - self, - size: tuple[int, int], - method: _GetDataTransform, - data: None = None, - resample: int = Resampling.NEAREST, - fill: int = 1, - fillcolor: float | tuple[float, ...] | str | None = None, - ) -> Image: ... - @overload def transform( self, size: tuple[int, int], method: Transform | ImageTransformHandler | _GetDataTransform, - data: Sequence[float] | None = None, - resample: int = Resampling.NEAREST, - fill: int = 1, - fillcolor: float | tuple[float, ...] | str | None = None, - ) -> Image: ... - def transform( - self, - size: tuple[int, int], - method: Transform | ImageTransformHandler | _GetDataTransform, - data: Sequence[float] | None = None, + data: Sequence[Any] | None = None, resample: int = Resampling.NEAREST, fill: int = 1, fillcolor: float | tuple[float, ...] | str | None = None, @@ -2803,7 +2775,7 @@ class Image: im.info = self.info.copy() if method == Transform.MESH: # list of quads - for box, quad in data: + for box, quad in cast(Sequence[tuple[float, float]], data): im.__transformer( box, self, Transform.QUAD, quad, resample, fillcolor is None ) @@ -2961,7 +2933,7 @@ class ImageTransformHandler: self, size: tuple[int, int], image: Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: dict[str, str | int | tuple[int, ...] | list[int]] | int, ) -> Image: pass @@ -3830,7 +3802,7 @@ class Exif(_ExifBase): return self._fixup_dict(info) def _get_head(self): - version = b"\x2B" if self.bigtiff else b"\x2A" + version = b"\x2b" if self.bigtiff else b"\x2a" if self.endian == "<": head = b"II" + version + b"\x00" + o32le(8) else: diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 579489fde..ec8a9a67d 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -118,7 +118,7 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font - def _getfont(self, font_size: float | None): + def _getfont(self, font_size: float | None) -> FreeTypeFont | ImageFont: if font_size is not None: from . import ImageFont @@ -451,13 +451,13 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text: str | bytes) -> bool: - split_character = "\n" if isinstance(text, str) else b"\n" + def _multiline_check(self, text: AnyStr) -> bool: + split_character = cast(AnyStr, "\n" if isinstance(text, str) else b"\n") return split_character in text def _multiline_split(self, text: AnyStr) -> list[AnyStr]: - split_character = "\n" if isinstance(text, str) else b"\n" + split_character = cast(AnyStr, "\n" if isinstance(text, str) else b"\n") return text.split(split_character) @@ -470,10 +470,10 @@ class ImageDraw: def text( self, - xy: tuple[int, int], - text, + xy: tuple[float, float], + text: str, fill=None, - font=None, + font: FreeTypeFont | ImageFont | None = None, anchor=None, spacing=4, align="left", @@ -527,7 +527,7 @@ class ImageDraw: coord.append(int(xy[i])) start.append(math.modf(xy[i])[0]) try: - mask, offset = font.getmask2( + mask, offset = font.getmask2( # type: ignore[union-attr,misc] text, mode, direction=direction, @@ -543,7 +543,7 @@ class ImageDraw: coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: - mask = font.getmask( + mask = font.getmask( # type: ignore[misc] text, mode, direction, @@ -592,7 +592,7 @@ class ImageDraw: def multiline_text( self, - xy: tuple[int, int], + xy: tuple[float, float], text, fill=None, font=None, @@ -625,7 +625,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: @@ -779,7 +779,7 @@ class ImageDraw: font = self._getfont(font_size) widths = [] - max_width = 0 + max_width: float = 0 lines = self._multiline_split(text) line_spacing = self._multiline_spacing(font, spacing, stroke_width) for line in lines: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index a1b722765..9eca3bc98 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -35,11 +35,14 @@ from enum import IntEnum from io import BytesIO from typing import TYPE_CHECKING, BinaryIO +from PIL import ImageFile + from . import Image from ._typing import StrOrBytesPath from ._util import is_directory, is_path if TYPE_CHECKING: + from ._imaging import ImagingFont from ._imagingft import Font @@ -84,11 +87,11 @@ def _string_length_check(text: str | bytes) -> None: class ImageFont: """PIL font wrapper""" - font: Font + font: ImagingFont def _load_pilfont(self, filename: str) -> None: with open(filename, "rb") as fp: - image = None + image: ImageFile.ImageFile | None = None for ext in (".png", ".gif", ".pbm"): if image: image.close() @@ -198,6 +201,8 @@ class ImageFont: class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" + font: Font + def __init__( self, font: StrOrBytesPath | BinaryIO | None = None, @@ -261,7 +266,7 @@ class FreeTypeFont: path, size, index, encoding, layout_engine = state self.__init__(path, size, index, encoding, layout_engine) - def getname(self) -> tuple[str, str]: + 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) @@ -876,6 +881,7 @@ def load_path(filename: str | bytes) -> ImageFont: """ for directory in sys.path: if is_directory(directory): + assert isinstance(directory, str) if not isinstance(filename, str): filename = filename.decode("utf-8") try: @@ -900,6 +906,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: :return: A font object. """ + f: FreeTypeFont | ImageFont if core.__class__.__name__ == "module" or size is not None: f = truetype( BytesIO( diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 6aa82dadd..80a6116b7 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,7 +14,7 @@ # from __future__ import annotations -from typing import Sequence +from typing import Any, Sequence from . import Image @@ -34,7 +34,7 @@ class Transform(Image.ImageTransformHandler): self, size: tuple[int, int], image: Image.Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]], + **options: Any, ) -> Image.Image: """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index e27843e53..d85eb84fa 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,3 +1,18 @@ from typing import Any +from typing_extensions import Buffer + +class ImagingCore: + def __getattr__(self, name: str) -> Any: ... + +class ImagingFont: + def __getattr__(self, name: str) -> Any: ... + +class ImagingDraw: + def __getattr__(self, name: str) -> Any: ... + +class PixelAccess: + def __getattr__(self, name: str) -> Any: ... + +def font(image, glyphdata: Buffer) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... From ef35d7926439e6fe8c36abc0846f859aaf3a893d Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Wed, 8 May 2024 12:14:37 +0200 Subject: [PATCH 16/91] Python 3.8 compatibility --- src/PIL/Image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9b0c24ec0..31e6fdb83 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2204,7 +2204,7 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - size = cast(tuple[int, int], tuple(size)) + size = cast("tuple[int, int]", tuple(size)) self.load() if box is None: From 7ae8d37138c8678e4a84210aa899df422fadaab1 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Wed, 8 May 2024 12:14:59 +0200 Subject: [PATCH 17/91] Make `GetDataTransform` public --- src/PIL/Image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 31e6fdb83..ed1621e62 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -482,7 +482,7 @@ def _getscaleoffset(expr): # Implementation wrapper -class _GetDataTransform(Protocol): +class GetDataTransform(Protocol): def getdata( self, ) -> tuple[Transform, Sequence[Any]]: ... @@ -2695,7 +2695,7 @@ class Image: def transform( self, size: tuple[int, int], - method: Transform | ImageTransformHandler | _GetDataTransform, + method: Transform | ImageTransformHandler | GetDataTransform, data: Sequence[Any] | None = None, resample: int = Resampling.NEAREST, fill: int = 1, From 296050f3823c4648e6e7eb351e433343eddc9cee Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Wed, 8 May 2024 12:26:45 +0200 Subject: [PATCH 18/91] More Python 3.8 compatibility --- src/PIL/Image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index ed1621e62..8348ea257 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2210,7 +2210,7 @@ class Image: if box is None: box = (0, 0) + self.size else: - box = cast(tuple[float, float, float, float], tuple(box)) + box = cast("tuple[float, float, float, float]", tuple(box)) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2268,7 +2268,7 @@ class Image: if box is None: box = (0, 0) + self.size else: - box = cast(tuple[int, int, int, int], tuple(box)) + box = cast("tuple[int, int, int, int]", tuple(box)) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() From bb8718e58162cdcd6a9b80eca45f7b2c8321bca9 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Wed, 8 May 2024 12:54:44 +0200 Subject: [PATCH 19/91] Hopefully the last Python 3.8 instance :/ --- src/PIL/Image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 8348ea257..f39580996 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2775,7 +2775,7 @@ class Image: im.info = self.info.copy() if method == Transform.MESH: # list of quads - for box, quad in cast(Sequence[tuple[float, float]], data): + for box, quad in cast("Sequence[tuple[float, float]]", data): im.__transformer( box, self, Transform.QUAD, quad, resample, fillcolor is None ) From 47580f257b1ae7c9b461108d1bf4ba7d2b65f1ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 9 May 2024 08:51:12 +1000 Subject: [PATCH 20/91] Updated libjpeg-turbo to 3.0.3 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..930289c2a 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -18,7 +18,7 @@ ARCHIVE_SDIR=pillow-depends-main FREETYPE_VERSION=2.13.2 HARFBUZZ_VERSION=8.4.0 LIBPNG_VERSION=1.6.43 -JPEGTURBO_VERSION=3.0.2 +JPEGTURBO_VERSION=3.0.3 OPENJPEG_VERSION=2.5.2 XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..9875d71e7 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -114,7 +114,7 @@ V = { "FREETYPE": "2.13.2", "FRIBIDI": "1.0.13", "HARFBUZZ": "8.4.0", - "JPEGTURBO": "3.0.2", + "JPEGTURBO": "3.0.3", "LCMS2": "2.16", "LIBPNG": "1.6.43", "LIBWEBP": "1.3.2", From 431fe0dcc8ff8a28fbe89c1668d7090f247aaed8 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Fri, 10 May 2024 11:46:35 +0200 Subject: [PATCH 21/91] Rename protocol to SupportsGetData --- src/PIL/Image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f39580996..154862a6f 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -482,7 +482,7 @@ def _getscaleoffset(expr): # Implementation wrapper -class GetDataTransform(Protocol): +class SupportsGetData(Protocol): def getdata( self, ) -> tuple[Transform, Sequence[Any]]: ... @@ -2695,7 +2695,7 @@ class Image: def transform( self, size: tuple[int, int], - method: Transform | ImageTransformHandler | GetDataTransform, + method: Transform | ImageTransformHandler | SupportsGetData, data: Sequence[Any] | None = None, resample: int = Resampling.NEAREST, fill: int = 1, From 9b44abb6b7f77043ac337fe8171d0ecdbb4b7882 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Fri, 10 May 2024 11:48:36 +0200 Subject: [PATCH 22/91] Add SupportsGetData to documentation --- docs/reference/Image.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 0d9b4d93d..c0d9095cd 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -365,6 +365,12 @@ Classes .. autoclass:: PIL.Image.ImagePointHandler .. autoclass:: PIL.Image.ImageTransformHandler +Protocols +--------- + +.. autoclass:: SupportsGetData + :show-inheritance: + Constants --------- From 13cf2bc70f4bb5de7c0a083303d4a232104d7852 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 11 May 2024 11:16:52 +1000 Subject: [PATCH 23/91] Moved SupportsArrayInterface under Protocols heading --- docs/reference/Image.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index c0d9095cd..d917a3c92 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -78,8 +78,6 @@ Constructing images ^^^^^^^^^^^^^^^^^^^ .. autofunction:: new -.. autoclass:: SupportsArrayInterface - :show-inheritance: .. autofunction:: fromarray .. autofunction:: frombytes .. autofunction:: frombuffer @@ -368,6 +366,8 @@ Classes Protocols --------- +.. autoclass:: SupportsArrayInterface + :show-inheritance: .. autoclass:: SupportsGetData :show-inheritance: From 6310280428a49ea5495953a824b1dfa85a4d5223 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Sat, 11 May 2024 10:44:52 +0200 Subject: [PATCH 24/91] Move an import behind the TYPE_CHECKING flag Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/ImageFont.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 9eca3bc98..f2936bae6 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -35,13 +35,12 @@ from enum import IntEnum from io import BytesIO from typing import TYPE_CHECKING, BinaryIO -from PIL import ImageFile - from . import Image from ._typing import StrOrBytesPath from ._util import is_directory, is_path if TYPE_CHECKING: + from . import ImageFile from ._imaging import ImagingFont from ._imagingft import Font From 6d6dfd176cf00a864a62dff6dd881099cc3bcec8 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Sat, 11 May 2024 10:46:20 +0200 Subject: [PATCH 25/91] Revert unnecessary formatting change --- src/PIL/Image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 154862a6f..53f38f0b2 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -3802,7 +3802,7 @@ class Exif(_ExifBase): return self._fixup_dict(info) def _get_head(self): - version = b"\x2b" if self.bigtiff else b"\x2a" + version = b"\x2B" if self.bigtiff else b"\x2A" if self.endian == "<": head = b"II" + version + b"\x00" + o32le(8) else: From e9b15f8091431de34d1a5f82cc0cf954d2cf2d6c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 14 May 2024 10:09:44 +1000 Subject: [PATCH 26/91] Updated harfbuzz to 8.5.0 --- .github/workflows/wheels-dependencies.sh | 2 +- winbuild/build_prepare.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 0d45d5a20..c5b279a33 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -16,7 +16,7 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.2 -HARFBUZZ_VERSION=8.4.0 +HARFBUZZ_VERSION=8.5.0 LIBPNG_VERSION=1.6.43 JPEGTURBO_VERSION=3.0.2 OPENJPEG_VERSION=2.5.2 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..b654ee8da 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,7 +113,7 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.2", "FRIBIDI": "1.0.13", - "HARFBUZZ": "8.4.0", + "HARFBUZZ": "8.5.0", "JPEGTURBO": "3.0.2", "LCMS2": "2.16", "LIBPNG": "1.6.43", From b2316f46cb4fc084fc15cb2848eca8b19cbc4329 Mon Sep 17 00:00:00 2001 From: Sebastian Rittau Date: Sat, 18 May 2024 11:22:57 +0200 Subject: [PATCH 27/91] Use just `str` for `_string_length_check` Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/ImageFont.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index f2936bae6..747c0c050 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -61,7 +61,7 @@ except ImportError as ex: core = DeferredError.new(ex) -def _string_length_check(text: str | bytes) -> None: +def _string_length_check(text: str) -> None: if MAX_STRING_LENGTH is not None and len(text) > MAX_STRING_LENGTH: msg = "too many characters in string" raise ValueError(msg) From 0f1a0fc5018cfda48a1e9368d4f421734eca4a18 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 20 May 2024 23:11:50 +1000 Subject: [PATCH 28/91] Improved consistency of XMP handling --- Tests/test_file_jpeg.py | 1 + Tests/test_file_png.py | 1 + Tests/test_file_tiff.py | 1 + Tests/test_file_webp_metadata.py | 1 + Tests/test_image.py | 4 ++++ docs/reference/Image.rst | 1 + docs/releasenotes/8.3.0.rst | 6 +++--- src/PIL/Image.py | 16 ++++++++++++---- src/PIL/ImageOps.py | 3 +++ src/PIL/JpegImagePlugin.py | 17 ++--------------- src/PIL/PngImagePlugin.py | 15 ++------------- src/PIL/TiffImagePlugin.py | 13 ++++--------- src/PIL/WebPImagePlugin.py | 9 --------- 13 files changed, 35 insertions(+), 53 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 5d2157651..c895c3a8d 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -943,6 +943,7 @@ class TestFileJpeg: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 19462dcb5..eb2c8a5f1 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -683,6 +683,7 @@ class TestFilePng: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a..56b9148d1 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -759,6 +759,7 @@ class TestFileTiff: ): assert im.getxmp() == {} else: + assert "xmp" in im.info xmp = im.getxmp() description = xmp["xmpmeta"]["RDF"]["Description"] diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index 875941240..c3df4ad7b 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -129,6 +129,7 @@ def test_getxmp() -> None: ): assert im.getxmp() == {} else: + assert "xmp" in im.info assert ( im.getxmp()["xmpmeta"]["xmptk"] == "Adobe XMP Core 5.3-c011 66.145661, 2012/02/06-14:56:27 " diff --git a/Tests/test_image.py b/Tests/test_image.py index 742d0dfe4..042c0dccb 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -897,6 +897,10 @@ class TestImage: assert tag not in exif.get_ifd(0x8769) assert exif.get_ifd(0xA005) + def test_empty_xmp(self) -> None: + with Image.open("Tests/images/hopper.gif") as im: + assert im.getxmp() == {} + @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index 0d9b4d93d..767c833b3 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -197,6 +197,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getpalette .. automethod:: PIL.Image.Image.getpixel .. automethod:: PIL.Image.Image.getprojection +.. automethod:: PIL.Image.Image.getxmp .. automethod:: PIL.Image.Image.histogram .. automethod:: PIL.Image.Image.paste .. automethod:: PIL.Image.Image.point diff --git a/docs/releasenotes/8.3.0.rst b/docs/releasenotes/8.3.0.rst index 9f46cc1e9..4ef914f64 100644 --- a/docs/releasenotes/8.3.0.rst +++ b/docs/releasenotes/8.3.0.rst @@ -18,9 +18,9 @@ is not secure. - :py:meth:`~PIL.Image.Image.getexif` has used ``xml`` to potentially retrieve orientation data since Pillow 7.2.0. It has been refactored to use ``re`` instead. -- :py:meth:`~PIL.JpegImagePlugin.JpegImageFile.getxmp` was added in Pillow 8.2.0. It - will now use ``defusedxml`` instead. If the dependency is not present, an empty - dictionary will be returned and a warning raised. +- ``getxmp()`` was added to :py:class:`~PIL.JpegImagePlugin.JpegImageFile` in Pillow + 8.2.0. It will now use ``defusedxml`` instead. If the dependency is not present, an + empty dictionary will be returned and a warning raised. Deprecations ============ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 958b95e3b..7d54b124b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1439,7 +1439,14 @@ class Image: return tuple(self.im.getband(i).getextrema() for i in range(self.im.bands)) return self.im.getextrema() - def _getxmp(self, xmp_tags): + def getxmp(self): + """ + Returns a dictionary containing the XMP tags. + Requires defusedxml to be installed. + + :returns: XMP tags in a dictionary. + """ + def get_name(tag): return re.sub("^{[^}]+}", "", tag) @@ -1466,9 +1473,10 @@ class Image: if ElementTree is None: warnings.warn("XMP data cannot be read without defusedxml dependency") return {} - else: - root = ElementTree.fromstring(xmp_tags) - return {get_name(root.tag): get_value(root)} + if "xmp" not in self.info: + return {} + root = ElementTree.fromstring(self.info["xmp"]) + return {get_name(root.tag): get_value(root)} def getexif(self) -> Exif: """ diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 33db8fa50..48bde39fe 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -717,6 +717,9 @@ def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image exif_image.info["XML:com.adobe.xmp"] = re.sub( pattern, "", exif_image.info["XML:com.adobe.xmp"] ) + exif_image.info["xmp"] = re.sub( + pattern.encode(), b"", exif_image.info["xmp"] + ) if not in_place: return transposed_image elif not in_place: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 909911dfe..be6b6959a 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -94,6 +94,8 @@ def APP(self, marker): else: self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 + elif marker == 0xFFE1 and s[:29] == b"http://ns.adobe.com/xap/1.0/\x00": + self.info["xmp"] = s.split(b"\x00")[1] elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) self.info["flashpix"] = s # FIXME: value will change @@ -499,21 +501,6 @@ class JpegImageFile(ImageFile.ImageFile): def _getmp(self): return _getmp(self) - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - - 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): if "exif" not in self.info: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index c74cbccf1..14622349b 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -606,6 +606,8 @@ class PngStream(ChunkStream): return s else: return s + if k == b"XML:com.adobe.xmp": + self.im_info["xmp"] = v try: k = k.decode("latin-1", "strict") lang = lang.decode("utf-8", "strict") @@ -1032,19 +1034,6 @@ class PngImageFile(ImageFile.ImageFile): return super().getexif() - def getxmp(self): - """ - Returns a dictionary containing the XMP tags. - Requires defusedxml to be installed. - - :returns: XMP tags in a dictionary. - """ - return ( - self._getxmp(self.info["XML:com.adobe.xmp"]) - if "XML:com.adobe.xmp" in self.info - else {} - ) - # -------------------------------------------------------------------- # PNG writer diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 54faa59c5..7ba71b58e 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1192,6 +1192,10 @@ class TiffImageFile(ImageFile.ImageFile): self.__frame += 1 self.fp.seek(self._frame_pos[frame]) self.tag_v2.load(self.fp) + if XMP in self.tag_v2: + self.info["xmp"] = self.tag_v2[XMP] + elif "xmp" in self.info: + del self.info["xmp"] self._reload_exif() # fill the legacy tag/ifd entries self.tag = self.ifd = ImageFileDirectory_v1.from_v2(self.tag_v2) @@ -1202,15 +1206,6 @@ class TiffImageFile(ImageFile.ImageFile): """Return the current frame number""" return self.__frame - 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.tag_v2[XMP]) if XMP in self.tag_v2 else {} - def get_photoshop_blocks(self): """ Returns a dictionary of Photoshop "Image Resource Blocks". diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index cae124e9f..43e1410c7 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -100,15 +100,6 @@ class WebPImageFile(ImageFile.ImageFile): 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: int) -> None: if not self._seek_check(frame): return From 2dedeef832eb170dd8490ca12aa496ade95de478 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 22 May 2024 20:05:07 +1000 Subject: [PATCH 29/91] Support unpacking more rawmodes to RGBA palette --- Tests/test_image_putpalette.py | 1 + src/PIL/DdsImagePlugin.py | 1 + src/PIL/GifImagePlugin.py | 2 +- src/PIL/Image.py | 12 ++++++------ src/_imaging.c | 6 +++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index cc7cf58f0..75d9d2fc1 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -79,6 +79,7 @@ def test_putpalette_with_alpha_values() -> None: ( ("RGBA", (1, 2, 3, 4)), ("RGBAX", (1, 2, 3, 4, 0)), + ("ARGB", (4, 1, 2, 3)), ), ) def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 1575f2d88..285e4b9db 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -379,6 +379,7 @@ class DdsImageFile(ImageFile.ImageFile): elif pfflags & DDPF.PALETTEINDEXED8: self._mode = "P" self.palette = ImagePalette.raw("RGBA", self.fp.read(1024)) + self.palette.mode = "RGBA" elif pfflags & DDPF.FOURCC: offset = header_size + 4 if fourcc == D3DFMT.DXT1: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index eede41549..151feb17d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -429,7 +429,7 @@ 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._mode = temp_mode diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 958b95e3b..7a957e769 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -873,7 +873,7 @@ class Image: if self.im is not None and self.palette and self.palette.dirty: # realize palette mode, arr = self.palette.getdata() - self.im.putpalette(mode, arr) + self.im.putpalette(self.palette.mode, mode, arr) self.palette.dirty = 0 self.palette.rawmode = None if "transparency" in self.info and mode in ("LA", "PA"): @@ -883,9 +883,9 @@ class Image: self.im.putpalettealphas(self.info["transparency"]) self.palette.mode = "RGBA" else: - palette_mode = "RGBA" if mode.startswith("RGBA") else "RGB" - self.palette.mode = palette_mode - self.palette.palette = self.im.getpalette(palette_mode, palette_mode) + self.palette.palette = self.im.getpalette( + self.palette.mode, self.palette.mode + ) if self.im is not None: if cffi and USE_CFFI_ACCESS: @@ -1998,7 +1998,7 @@ class Image: palette = ImagePalette.raw(rawmode, data) self._mode = "PA" if "A" in self.mode else "P" self.palette = palette - self.palette.mode = "RGB" + self.palette.mode = "RGBA" if "A" in rawmode else "RGB" self.load() # install new palette def putpixel(self, xy, value): @@ -2113,7 +2113,7 @@ class Image: # m_im.putpalette(mapping_palette, 'L') # converts to 'P' # or just force it. # UNDONE -- this is part of the general issue with palettes - m_im.im.putpalette(palette_mode + ";L", m_im.palette.tobytes()) + m_im.im.putpalette(palette_mode, palette_mode + ";L", m_im.palette.tobytes()) m_im = m_im.convert("L") diff --git a/src/_imaging.c b/src/_imaging.c index c565c21bb..d2d29bfdf 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -1725,10 +1725,11 @@ _putpalette(ImagingObject *self, PyObject *args) { ImagingShuffler unpack; int bits; - char *rawmode, *palette_mode; + char *palette_mode, *rawmode; UINT8 *palette; Py_ssize_t palettesize; - if (!PyArg_ParseTuple(args, "sy#", &rawmode, &palette, &palettesize)) { + if (!PyArg_ParseTuple( + args, "ssy#", &palette_mode, &rawmode, &palette, &palettesize)) { return NULL; } @@ -1738,7 +1739,6 @@ _putpalette(ImagingObject *self, PyObject *args) { return NULL; } - palette_mode = strncmp("RGBA", rawmode, 4) == 0 ? "RGBA" : "RGB"; unpack = ImagingFindUnpacker(palette_mode, rawmode, &bits); if (!unpack) { PyErr_SetString(PyExc_ValueError, wrong_raw_mode); From 2c9b5f03607d083665f5880506197405197f34ae Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 25 May 2024 06:20:03 +1000 Subject: [PATCH 30/91] Updated Ghostscript to 10.3.1 --- .appveyor.yml | 2 +- .github/workflows/test-windows.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.appveyor.yml b/.appveyor.yml index 57a8fa5a0..6470dbc4c 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -34,7 +34,7 @@ install: - xcopy /S /Y c:\test-images-main\* c:\pillow\tests\images - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.01-win64.zip - 7z x nasm-win64.zip -oc:\ -- choco install ghostscript --version=10.3.0 +- choco install ghostscript --version=10.3.1 - path c:\nasm-2.16.01;C:\Program Files\gs\gs10.00.0\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 9edc15173..ee265774b 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -86,7 +86,7 @@ jobs: choco install nasm --no-progress echo "C:\Program Files\NASM" >> $env:GITHUB_PATH - choco install ghostscript --version=10.3.0 --no-progress + choco install ghostscript --version=10.3.1 --no-progress echo "C:\Program Files\gs\gs10.00.0\bin" >> $env:GITHUB_PATH # Install extra test images From d566c04d5b9b2f7587015b110e588b073a24cf2d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 3 Jun 2024 14:20:01 +1000 Subject: [PATCH 31/91] Updated type hints --- src/PIL/Image.py | 22 +++++++--------- src/PIL/ImageDraw.py | 29 +++++++++++++++------ src/PIL/ImageFont.py | 53 +++++++++++++++++++------------------- src/PIL/JpegImagePlugin.py | 2 +- src/PIL/_imaging.pyi | 4 +-- src/PIL/_imagingft.pyi | 23 ++++++++++++----- 6 files changed, 75 insertions(+), 58 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index c02c7d6b6..2ea26877d 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -506,7 +506,7 @@ def _getscaleoffset(expr): class SupportsGetData(Protocol): def getdata( self, - ) -> tuple[Transform, Sequence[Any]]: ... + ) -> tuple[Transform, Sequence[int]]: ... class Image: @@ -1295,7 +1295,7 @@ class Image: return im.crop((x0, y0, x1, y1)) def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the @@ -1719,7 +1719,7 @@ class Image: def paste( self, - im: Image | str | int | tuple[int, ...], + im: Image | str | float | tuple[int, ...], box: tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None, ) -> None: @@ -1750,7 +1750,7 @@ class Image: See :py:meth:`~PIL.Image.Image.alpha_composite` if you want to combine images with respect to their alpha channels. - :param im: Source image or pixel value (integer or tuple). + :param im: Source image or pixel value (integer, float or tuple). :param box: An optional 4-tuple giving the region to paste into. If a 2-tuple is used instead, it's treated as the upper left corner. If omitted or None, the source is pasted into the @@ -2228,13 +2228,9 @@ class Image: msg = "reducing_gap must be 1.0 or greater" raise ValueError(msg) - size = cast("tuple[int, int]", tuple(size)) - self.load() if box is None: box = (0, 0) + self.size - else: - box = cast("tuple[float, float, float, float]", tuple(box)) if self.size == size and box == (0, 0) + self.size: return self.copy() @@ -2291,8 +2287,6 @@ class Image: if box is None: box = (0, 0) + self.size - else: - box = cast("tuple[int, int, int, int]", tuple(box)) if factor == (1, 1) and box == (0, 0) + self.size: return self.copy() @@ -2692,7 +2686,9 @@ class Image: return size = preserved_size - res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap)) # type: ignore[arg-type] + res = self.draft( + None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) + ) if res is not None: box = res[1] if box is None: @@ -2799,7 +2795,7 @@ class Image: im.info = self.info.copy() if method == Transform.MESH: # list of quads - for box, quad in cast("Sequence[tuple[float, float]]", data): + for box, quad in data: im.__transformer( box, self, Transform.QUAD, quad, resample, fillcolor is None ) @@ -2957,7 +2953,7 @@ class ImageTransformHandler: self, size: tuple[int, int], image: Image, - **options: dict[str, str | int | tuple[int, ...] | list[int]] | int, + **options: str | int | tuple[int, ...] | list[int], ) -> Image: pass diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 1887a3933..0663d9ddf 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -456,14 +456,12 @@ class ImageDraw: self.draw.draw_rectangle(right, ink, 1) def _multiline_check(self, text: AnyStr) -> bool: - split_character = cast(AnyStr, "\n" if isinstance(text, str) else b"\n") + split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text def _multiline_split(self, text: AnyStr) -> list[AnyStr]: - split_character = cast(AnyStr, "\n" if isinstance(text, str) else b"\n") - - return text.split(split_character) + return text.split("\n" if isinstance(text, str) else b"\n") def _multiline_spacing(self, font, spacing, stroke_width): return ( @@ -477,7 +475,12 @@ class ImageDraw: xy: tuple[float, float], text: str, fill=None, - font: ImageFont.FreeTypeFont | ImageFont.ImageFont | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -597,9 +600,14 @@ class ImageDraw: def multiline_text( self, xy: tuple[float, float], - text, + text: str, fill=None, - font=None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, anchor=None, spacing=4, align="left", @@ -684,7 +692,12 @@ class ImageDraw: def textlength( self, text: str, - font: ImageFont.FreeTypeFont | ImageFont.ImageFont | None = None, + font: ( + ImageFont.ImageFont + | ImageFont.FreeTypeFont + | ImageFont.TransposedFont + | None + ) = None, direction=None, features=None, language=None, diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 747c0c050..a9925483e 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,11 +33,11 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from typing import TYPE_CHECKING, BinaryIO +from typing import IO, TYPE_CHECKING, Any, BinaryIO from . import Image from ._typing import StrOrBytesPath -from ._util import is_directory, is_path +from ._util import is_path if TYPE_CHECKING: from . import ImageFile @@ -61,7 +61,7 @@ except ImportError as ex: core = DeferredError.new(ex) -def _string_length_check(text: str) -> None: +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) @@ -113,7 +113,7 @@ 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: # read PILfont header if file.readline() != b"PILfont\n": msg = "Not a PILfont file" @@ -161,7 +161,7 @@ class ImageFont: return self.font.getmask(text, mode) def getbbox( - self, text: str, *args: object, **kwargs: object + self, text: str | bytes | bytearray, *args: Any, **kwargs: Any ) -> tuple[int, int, int, int]: """ Returns bounding box (in pixels) of given text. @@ -180,7 +180,9 @@ class ImageFont: width, height = self.font.getsize(text) return 0, 0, width, height - def getlength(self, text: str, *args: object, **kwargs: object) -> int: + 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. @@ -357,13 +359,13 @@ class FreeTypeFont: def getbbox( self, text: str, - mode="", - direction=None, - features=None, - language=None, - stroke_width=0, - anchor=None, - ) -> tuple[int, int, int, int]: + mode: str = "", + direction: str | None = None, + features: 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. @@ -513,7 +515,7 @@ class FreeTypeFont: def getmask2( self, - text, + text: str, mode="", direction=None, features=None, @@ -641,7 +643,7 @@ 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. @@ -683,10 +685,11 @@ class FreeTypeFont: msg = "FreeType 2.9.1 or greater is required" raise NotImplementedError(msg) from e for axis in axes: - axis["name"] = axis["name"].replace(b"\x00", b"") + if axis["name"]: + axis["name"] = axis["name"].replace(b"\x00", b"") return axes - def set_variation_by_axes(self, axes): + def set_variation_by_axes(self, axes: list[float]) -> None: """ :param axes: A list of values for each axis. :exception OSError: If the font is not a variation font. @@ -731,7 +734,7 @@ class TransposedFont: return 0, 0, height, width return 0, 0, width, height - def getlength(self, text, *args, **kwargs): + def getlength(self, text: str, *args, **kwargs) -> 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) @@ -878,15 +881,13 @@ def load_path(filename: str | bytes) -> ImageFont: :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): - assert isinstance(directory, str) - if not isinstance(filename, str): - filename = filename.decode("utf-8") - try: - return load(os.path.join(directory, filename)) - except OSError: - pass + try: + return load(os.path.join(directory, filename)) + except OSError: + pass msg = "cannot find font file" raise OSError(msg) diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 909911dfe..e1c61f991 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -425,7 +425,7 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft( - self, mode: str, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: return None diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index d85eb84fa..1fe954417 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -1,7 +1,5 @@ from typing import Any -from typing_extensions import Buffer - class ImagingCore: def __getattr__(self, name: str) -> Any: ... @@ -14,5 +12,5 @@ class ImagingDraw: class PixelAccess: def __getattr__(self, name: str) -> Any: ... -def font(image, glyphdata: Buffer) -> ImagingFont: ... +def font(image, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index 987e7fd6f..b023efe01 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -1,5 +1,7 @@ from typing import Any, TypedDict +from . import _imaging + class _Axis(TypedDict): minimum: int | None default: int | None @@ -37,21 +39,28 @@ class Font: x_start=..., y_start=..., /, - ) -> tuple[Any, tuple[int, int]]: ... + ) -> tuple[_imaging.ImagingCore, tuple[int, int]]: ... def getsize( - self, string: str, mode=..., dir=..., features=..., lang=..., anchor=..., / + self, + string: str | bytes | bytearray, + mode=..., + dir=..., + features=..., + lang=..., + anchor=..., + /, ) -> tuple[tuple[int, int], tuple[int, int]]: ... def getlength( self, string: str, mode=..., dir=..., features=..., lang=..., / - ) -> int: ... - def getvarnames(self) -> list[str]: ... - def getvaraxes(self) -> list[_Axis]: ... + ) -> float: ... + def getvarnames(self) -> list[bytes]: ... + def getvaraxes(self) -> list[_Axis] | None: ... def setvarname(self, instance_index: int, /) -> None: ... def setvaraxes(self, axes: list[float], /) -> None: ... def getfont( - filename: str | bytes | bytearray, - size, + filename: str | bytes, + size: float, index=..., encoding=..., font_bytes=..., From 923d4e5e1a971ea64ce56c5b016a620be33d51eb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Jun 2024 22:27:23 +1000 Subject: [PATCH 32/91] Added type hints --- Tests/bench_cffi_access.py | 1 + Tests/test_features.py | 2 +- Tests/test_file_bmp.py | 2 +- Tests/test_file_bufrstub.py | 11 ++++++----- Tests/test_file_gribstub.py | 4 ++-- Tests/test_file_hdf5stub.py | 7 ++++--- Tests/test_file_jpeg.py | 2 +- Tests/test_file_webp.py | 4 +++- Tests/test_file_webp_animated.py | 2 +- Tests/test_file_wmf.py | 12 ++++++++---- Tests/test_image_access.py | 6 ++++++ Tests/test_image_rotate.py | 4 ++-- Tests/test_image_thumbnail.py | 4 +++- Tests/test_imageops_usm.py | 1 - Tests/test_qt_image_qapplication.py | 2 +- src/PIL/BufrStubImagePlugin.py | 2 +- src/PIL/GribStubImagePlugin.py | 2 +- src/PIL/Hdf5StubImagePlugin.py | 2 +- src/PIL/WmfImagePlugin.py | 2 +- 19 files changed, 44 insertions(+), 28 deletions(-) diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index d2a08c07b..c7d105836 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -44,6 +44,7 @@ def test_direct() -> None: caccess = im.im.pixel_access(False) access = PyAccess.new(im, False) + assert access is not None assert caccess[(0, 0)] == access[(0, 0)] print(f"Size: {im.width}x{im.height}") diff --git a/Tests/test_features.py b/Tests/test_features.py index 59fb49809..de418115e 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -124,7 +124,7 @@ def test_unsupported_module() -> None: @pytest.mark.parametrize("supported_formats", (True, False)) -def test_pilinfo(supported_formats) -> None: +def test_pilinfo(supported_formats: bool) -> None: buf = io.StringIO() features.pilinfo(buf, supported_formats=supported_formats) out = buf.getvalue() diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c7c9b24e7..2ff4160bd 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -140,7 +140,7 @@ def test_load_dib() -> None: (124, "g/pal8v5.bmp"), ), ) -def test_dib_header_size(header_size, path): +def test_dib_header_size(header_size: int, path: str) -> None: image_path = "Tests/images/bmp/" + path with open(image_path, "rb") as fp: data = fp.read()[14:] diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 3dd24533a..939e82e77 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import BufrStubImagePlugin, Image +from PIL import BufrStubImagePlugin, Image, ImageFile from .helper import hopper @@ -50,20 +51,20 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False - def open(self, im) -> None: + def open(self, im: ImageFile.StubImageFile) -> None: self.opened = True - def load(self, im): + def load(self, im: ImageFile.StubImageFile) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename) -> None: + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 096a5b88b..86a9064fc 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -5,7 +5,7 @@ from typing import IO import pytest -from PIL import GribStubImagePlugin, Image +from PIL import GribStubImagePlugin, Image, ImageFile from .helper import hopper @@ -51,7 +51,7 @@ def test_save(tmp_path: Path) -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index f871e2eff..ee1544c51 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,11 +1,12 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path from typing import IO import pytest -from PIL import Hdf5StubImagePlugin, Image +from PIL import Hdf5StubImagePlugin, Image, ImageFile TEST_FILE = "Tests/images/hdf5.h5" @@ -41,7 +42,7 @@ def test_load() -> None: def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: - dummy_fp = None + dummy_fp = BytesIO() dummy_filename = "dummy.filename" # Act / Assert: stub cannot save without an implemented handler @@ -52,7 +53,7 @@ def test_save() -> None: def test_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): opened = False loaded = False saved = False diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 33f9ce00e..18dc752d8 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -171,7 +171,7 @@ class TestFileJpeg: [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], ) def test_dpi(self, test_image_path: str) -> None: - def test(xdpi: int, ydpi: int | None = None): + def test(xdpi: int, ydpi: int | None = None) -> tuple[int, int] | None: with Image.open(test_image_path) as im: im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) return im.info.get("dpi") diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index e2de84c71..1caf032f6 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -198,7 +198,9 @@ class TestFileWebp: (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) @skip_unless_feature("webp_anim") - def test_invalid_background(self, background, tmp_path: Path) -> None: + def test_invalid_background( + self, background: int | tuple[int, ...], tmp_path: Path + ) -> None: temp_file = str(tmp_path / "temp.webp") im = hopper() with pytest.raises(OSError): diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index ba931f864..882dccb32 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -69,7 +69,7 @@ def test_write_animation_RGB(tmp_path: Path) -> None: are visually similar to the originals. """ - def check(temp_file) -> None: + def check(temp_file: str) -> None: with Image.open(temp_file) as im: assert im.n_frames == 2 diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index b43e3f296..79e707263 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,10 +1,11 @@ from __future__ import annotations from pathlib import Path +from typing import IO import pytest -from PIL import Image, WmfImagePlugin +from PIL import Image, ImageFile, WmfImagePlugin from .helper import assert_image_similar_tofile, hopper @@ -34,10 +35,13 @@ def test_load() -> None: def test_register_handler(tmp_path: Path) -> None: - class TestHandler: + class TestHandler(ImageFile.StubHandler): methodCalled = False - def save(self, im, fp, filename) -> None: + def load(self, im: ImageFile.StubImageFile) -> Image.Image: + return Image.new("RGB", (1, 1)) + + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.methodCalled = True handler = TestHandler() @@ -70,7 +74,7 @@ def test_load_set_dpi() -> None: @pytest.mark.parametrize("ext", (".wmf", ".emf")) -def test_save(ext, tmp_path: Path) -> None: +def test_save(ext: str, tmp_path: Path) -> None: im = hopper() tmpfile = str(tmp_path / ("temp" + ext)) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 9d6006679..8abb1f69f 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -259,6 +259,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -289,6 +290,7 @@ class TestCffi(AccessTest): caccess = im.im.pixel_access(False) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None w, h = im.size for x in range(0, w, 10): @@ -299,6 +301,8 @@ class TestCffi(AccessTest): # Attempt to set the value on a read-only image with pytest.warns(DeprecationWarning): access = PyAccess.new(im, True) + assert access is not None + with pytest.raises(ValueError): access[(0, 0)] = color @@ -341,6 +345,8 @@ class TestCffi(AccessTest): im = Image.new(mode, (1, 1)) with pytest.warns(DeprecationWarning): access = PyAccess.new(im, False) + assert access is not None + access.putpixel((0, 0), color) if len(color) == 3: diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index c10c96da6..252a15db7 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -124,8 +124,8 @@ def test_fastpath_translate() -> None: def test_center() -> None: im = hopper() rotate(im, im.mode, 45, center=(0, 0)) - rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) - rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) + rotate(im, im.mode, 45, translate=(im.size[0] // 2, 0)) + rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] // 2, 0)) def test_rotate_no_fill() -> None: diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 2ca1d2cfc..1593eaaf7 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -111,7 +111,9 @@ def test_load_first_unless_jpeg() -> None: with Image.open("Tests/images/hopper.jpg") as im: draft = im.draft - def im_draft(mode: str, size: tuple[int, int]): + def im_draft( + mode: str, size: tuple[int, int] + ) -> tuple[str, tuple[int, int, float, float]] | None: result = draft(mode, size) assert result is not None diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 104c620de..dbdd5b317 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -58,7 +58,6 @@ def test_blur_formats(test_images: dict[str, ImageFile.ImageFile]) -> None: blur = ImageFilter.GaussianBlur with pytest.raises(ValueError): im.convert("1").filter(blur) - blur(im.convert("L")) with pytest.raises(ValueError): im.convert("I").filter(blur) with pytest.raises(ValueError): diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 3cd323553..28f66891c 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -46,7 +46,7 @@ def roundtrip(expected: Image.Image) -> None: @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_sanity(tmp_path: Path) -> None: # Segfault test - app = QApplication([]) + app: QApplication | None = QApplication([]) ex = Example() assert app # Silence warning assert ex # Silence warning diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 6f52204b8..7388a2b8a 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -17,7 +17,7 @@ from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific BUFR image handler. diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index b24dcded2..d3655f4dd 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -17,7 +17,7 @@ from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific GRIB image handler. diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index c8d7866a3..b789c215f 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -17,7 +17,7 @@ from . import Image, ImageFile _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific HDF5 image handler. diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 25a4545db..a68f705a0 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -30,7 +30,7 @@ from ._binary import si32le as _long _handler = None -def register_handler(handler: ImageFile.StubHandler) -> None: +def register_handler(handler: ImageFile.StubHandler | None) -> None: """ Install application-specific WMF image handler. From 44805bcd1d34446af734052f91428844a604540b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 7 Jun 2024 16:49:03 +1000 Subject: [PATCH 33/91] Updated fribidi to 1.0.15 --- winbuild/build_prepare.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 0d6da7754..7ec1eaa82 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -112,7 +112,7 @@ ARCHITECTURES = { V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.2", - "FRIBIDI": "1.0.13", + "FRIBIDI": "1.0.15", "HARFBUZZ": "8.4.0", "JPEGTURBO": "3.0.2", "LCMS2": "2.16", From d2603b779aec4810349621542b795e79c0144d8e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Jun 2024 15:42:24 +1000 Subject: [PATCH 34/91] im color could be a tuple with a single float --- src/PIL/Image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 2ea26877d..d9eb73e45 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1719,7 +1719,7 @@ class Image: def paste( self, - im: Image | str | float | tuple[int, ...], + im: Image | str | float | tuple[float, ...], box: tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None, ) -> None: From 45cdc53bbb609212590b0558061aa2991cc87a5d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Jun 2024 18:01:26 +1000 Subject: [PATCH 35/91] Updated type hints --- Tests/test_image_rotate.py | 4 ++-- docs/handbook/concepts.rst | 6 ++++++ docs/reference/Image.rst | 1 - src/PIL/Image.py | 10 +++++----- src/PIL/ImageDraw.py | 11 ++++++----- src/PIL/ImageFont.py | 2 +- src/PIL/_imagingft.pyi | 2 +- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index c10c96da6..252a15db7 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -124,8 +124,8 @@ def test_fastpath_translate() -> None: def test_center() -> None: im = hopper() rotate(im, im.mode, 45, center=(0, 0)) - rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) - rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) + rotate(im, im.mode, 45, translate=(im.size[0] // 2, 0)) + rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] // 2, 0)) def test_rotate_no_fill() -> None: diff --git a/docs/handbook/concepts.rst b/docs/handbook/concepts.rst index 5094dbf3f..7da1078c1 100644 --- a/docs/handbook/concepts.rst +++ b/docs/handbook/concepts.rst @@ -144,10 +144,12 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. py:currentmodule:: PIL.Image .. data:: Resampling.NEAREST + :noindex: Pick one nearest pixel from the input image. Ignore all other input pixels. .. data:: Resampling.BOX + :noindex: Each pixel of source image contributes to one pixel of the destination image with identical weights. @@ -158,6 +160,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BILINEAR + :noindex: For resize calculate the output pixel value using linear interpolation on all pixels that may contribute to the output value. @@ -165,6 +168,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.HAMMING + :noindex: Produces a sharper image than :data:`Resampling.BILINEAR`, doesn't have dislocations on local level like with :data:`Resampling.BOX`. @@ -174,6 +178,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. .. versionadded:: 3.4.0 .. data:: Resampling.BICUBIC + :noindex: For resize calculate the output pixel value using cubic interpolation on all pixels that may contribute to the output value. @@ -181,6 +186,7 @@ pixel, the Python Imaging Library provides different resampling *filters*. in the input image is used. .. data:: Resampling.LANCZOS + :noindex: Calculate the output pixel value using a high-quality Lanczos filter (a truncated sinc) on all pixels that may contribute to the output value. diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index d917a3c92..1c095a114 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -424,7 +424,6 @@ See :ref:`concept-filters` for details. .. autoclass:: Resampling :members: :undoc-members: - :noindex: Dither modes ^^^^^^^^^^^^ diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d9eb73e45..13d374345 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2303,8 +2303,8 @@ class Image: def rotate( self, angle: float, - resample: int = Resampling.NEAREST, - expand: bool = False, + resample: Resampling = Resampling.NEAREST, + expand: int | bool = False, center: tuple[int, int] | None = None, translate: tuple[int, int] | None = None, fillcolor: float | tuple[float, ...] | str | None = None, @@ -2617,8 +2617,8 @@ class Image: def thumbnail( self, - size: tuple[int, int], - resample: int = Resampling.BICUBIC, + size: tuple[float, float], + resample: Resampling = Resampling.BICUBIC, reducing_gap: float = 2.0, ) -> None: """ @@ -2953,7 +2953,7 @@ class ImageTransformHandler: self, size: tuple[int, int], image: Image, - **options: str | int | tuple[int, ...] | list[int], + **options: Any, ) -> Image: pass diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 0663d9ddf..9796189bb 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -95,7 +95,9 @@ class ImageDraw: if TYPE_CHECKING: from . import ImageFont - def getfont(self) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + def getfont( + self, + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: """ Get the current default font. @@ -122,14 +124,13 @@ class ImageDraw: def _getfont( self, font_size: float | None - ) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: if font_size is not None: from . import ImageFont - font = ImageFont.load_default(font_size) + return ImageFont.load_default(font_size) else: - font = self.getfont() - return font + return self.getfont() def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: if ink is None and fill is None: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index a9925483e..87261f519 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -361,7 +361,7 @@ class FreeTypeFont: text: str, mode: str = "", direction: str | None = None, - features: str | None = None, + features: list[str] | None = None, language: str | None = None, stroke_width: float = 0, anchor: str | None = None, diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi index b023efe01..6e0ddd2f1 100644 --- a/src/PIL/_imagingft.pyi +++ b/src/PIL/_imagingft.pyi @@ -6,7 +6,7 @@ class _Axis(TypedDict): minimum: int | None default: int | None maximum: int | None - name: str | None + name: bytes | None class Font: @property From 985e6053810b7236e67dafcbfcd4b53e71e3fe3d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Jun 2024 19:06:46 +1000 Subject: [PATCH 36/91] Renamed transform2 to transform --- src/PIL/Image.py | 2 +- src/_imaging.c | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 611d6960d..f61acc1d3 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2883,7 +2883,7 @@ class Image: if image.mode in ("1", "P"): resample = Resampling.NEAREST - self.im.transform2(box, image.im, method, data, resample, fill) + self.im.transform(box, image.im, method, data, resample, fill) def transpose(self, method: Transpose) -> Image: """ diff --git a/src/_imaging.c b/src/_imaging.c index c565c21bb..f398c6c7c 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2028,7 +2028,7 @@ im_setmode(ImagingObject *self, PyObject *args) { } static PyObject * -_transform2(ImagingObject *self, PyObject *args) { +_transform(ImagingObject *self, PyObject *args) { static const char *wrong_number = "wrong number of matrix entries"; Imaging imOut; @@ -3647,7 +3647,7 @@ static struct PyMethodDef methods[] = { {"resize", (PyCFunction)_resize, METH_VARARGS}, {"reduce", (PyCFunction)_reduce, METH_VARARGS}, {"transpose", (PyCFunction)_transpose, METH_VARARGS}, - {"transform2", (PyCFunction)_transform2, METH_VARARGS}, + {"transform", (PyCFunction)_transform, METH_VARARGS}, {"isblock", (PyCFunction)_isblock, METH_NOARGS}, From 14a32650ddf8af6016045170e6e7490e1cdcc0b0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 8 Jun 2024 22:26:28 +1000 Subject: [PATCH 37/91] Added type hints --- src/PIL/ImageColor.py | 53 +++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index 5fb80b753..9a15a8eb7 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -25,7 +25,7 @@ from . import Image @lru_cache -def getrgb(color): +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. @@ -44,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): @@ -88,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( @@ -105,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) @@ -124,7 +126,7 @@ def getrgb(color): @lru_cache -def getcolor(color, mode: str) -> tuple[int, ...]: +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 @@ -136,33 +138,34 @@ def getcolor(color, mode: str) -> tuple[int, ...]: :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. From 56fa3c658a9c60facce7acbb3909855f7aea2340 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 9 Jun 2024 07:12:05 +1000 Subject: [PATCH 38/91] Added type hints --- src/PIL/GifImagePlugin.py | 15 +++++++++---- src/PIL/GimpGradientFile.py | 41 ++++++++++++++++++++++++++++-------- src/PIL/Image.py | 2 +- src/PIL/ImageDraw2.py | 6 +++--- src/PIL/ImageTk.py | 6 +++--- src/PIL/Jpeg2KImagePlugin.py | 3 ++- src/PIL/PaletteFile.py | 10 +++++---- src/PIL/TiffImagePlugin.py | 4 ++-- 8 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index e62852db3..f41bc2b32 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -558,7 +558,11 @@ def _normalize_palette(im, palette, info): return im -def _write_single_frame(im, fp, palette): +def _write_single_frame( + im: Image.Image, + fp: IO[bytes], + palette: bytes | bytearray | list[int] | ImagePalette.ImagePalette, +) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): im.encoderinfo.setdefault(k, v) @@ -579,7 +583,9 @@ def _write_single_frame(im, fp, palette): fp.write(b"\0") # end of image data -def _getbbox(base_im, im_frame): +def _getbbox( + base_im: Image.Image, im_frame: Image.Image +) -> tuple[Image.Image, tuple[int, int, int, int]]: if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") @@ -790,7 +796,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) -> None: # Unused by default. # To use, uncomment the register_save call at the end of the file. # @@ -821,6 +827,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() @@ -1080,7 +1087,7 @@ def getdata(im, offset=(0, 0), **params): class Collector: data = [] - def write(self, data): + def write(self, data: bytes) -> None: self.data.append(data) im.load() # make sure raster data is available diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 2d8c78ea9..92068b904 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -21,6 +21,7 @@ See the GIMP distribution for more information.) from __future__ import annotations from math import log, pi, sin, sqrt +from typing import IO, Callable from ._binary import o8 @@ -28,7 +29,7 @@ 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 +44,19 @@ def linear(middle, pos): return 0.5 + 0.5 * pos / middle -def curved(middle, pos): +def curved(middle: float, pos: float) -> float: return pos ** (log(0.5) / log(max(middle, EPSILON))) -def sine(middle, pos): +def sine(middle: float, pos: float) -> float: return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0 -def sphere_increasing(middle, pos): +def sphere_increasing(middle: float, pos: float) -> float: return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2) -def sphere_decreasing(middle, pos): +def sphere_decreasing(middle: float, pos: float) -> float: return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2) @@ -64,9 +65,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,7 +115,7 @@ class GradientFile: class GimpGradientFile(GradientFile): """File handler for GIMP's gradient format.""" - def __init__(self, fp): + def __init__(self, fp: IO[bytes]) -> None: if fp.readline()[:13] != b"GIMP Gradient": msg = "not a GIMP gradient file" raise SyntaxError(msg) @@ -114,7 +128,16 @@ class GimpGradientFile(GradientFile): count = int(line) - gradient = [] + gradient: list[ + tuple[ + float, + float, + float, + list[float], + list[float], + Callable[[float, float], float], + ] + ] = [] for i in range(count): s = fp.readline().split() diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f61acc1d3..f6ffac8a7 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2470,7 +2470,7 @@ class Image: save_all = params.pop("save_all", False) self.encoderinfo = params - self.encoderconfig = () + self.encoderconfig: tuple[Any, ...] = () preinit() diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 35ee5834e..b42f5d9ea 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -30,7 +30,7 @@ from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath 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,7 +38,7 @@ 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) @@ -63,7 +63,7 @@ class Draw: self.image = image self.transform = None - def flush(self): + def flush(self) -> Image.Image: return self.image def render(self, op, xy, pen, brush=None): diff --git a/src/PIL/ImageTk.py b/src/PIL/ImageTk.py index 6e2e7db1e..90defdbbc 100644 --- a/src/PIL/ImageTk.py +++ b/src/PIL/ImageTk.py @@ -37,7 +37,7 @@ from . import Image _pilbitmap_ok = None -def _pilbitmap_check(): +def _pilbitmap_check() -> int: global _pilbitmap_ok if _pilbitmap_ok is None: try: @@ -162,7 +162,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. @@ -254,7 +254,7 @@ 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 diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index e6395b1cb..5a0ef0d01 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import io import os import struct +from typing import IO from . import Image, ImageFile, ImagePalette, _binary @@ -328,7 +329,7 @@ def _accept(prefix: bytes) -> bool: # Save support -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: # Get the keyword arguments info = im.encoderinfo diff --git a/src/PIL/PaletteFile.py b/src/PIL/PaletteFile.py index eaed5367c..81652e5ee 100644 --- a/src/PIL/PaletteFile.py +++ b/src/PIL/PaletteFile.py @@ -14,6 +14,8 @@ # from __future__ import annotations +from typing import IO + from ._binary import o8 @@ -22,8 +24,8 @@ 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() @@ -44,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) -> tuple[bytes, str]: return self.palette, self.rawmode diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 04f36744b..0b9601755 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,7 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import TYPE_CHECKING, Any, Callable +from typing import IO, TYPE_CHECKING, Any, Callable from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -2149,7 +2149,7 @@ class AppendingTiffWriter: self.rewriteLastLong(offset) -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) From 1a14957c1954b18368a65bff06d8a959731d9e55 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 9 Jun 2024 15:16:17 +1000 Subject: [PATCH 39/91] Added type hints --- Tests/test_decompression_bomb.py | 2 +- Tests/test_file_jpeg.py | 17 ++++++++++------- Tests/test_file_tiff.py | 2 +- Tests/test_image_mode.py | 6 +++++- Tests/test_image_quantize.py | 2 +- Tests/test_image_reduce.py | 12 ++++++++---- Tests/test_image_thumbnail.py | 2 +- Tests/test_imagedraw.py | 6 +++++- Tests/test_imagefont.py | 2 +- Tests/test_imageops.py | 2 +- Tests/test_imagewin_pointers.py | 2 +- Tests/test_main.py | 2 +- 12 files changed, 36 insertions(+), 21 deletions(-) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 9c21efa45..c140156f9 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -12,7 +12,7 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS class TestDecompressionBomb: - def teardown_method(self, method) -> None: + def teardown_method(self) -> None: Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self) -> None: diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 18dc752d8..8e4d694c1 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -443,7 +443,9 @@ class TestFileJpeg: assert_image(im1, im2.mode, im2.size) def test_subsampling(self) -> None: - def getsampling(im: JpegImagePlugin.JpegImageFile): + def getsampling( + im: JpegImagePlugin.JpegImageFile, + ) -> tuple[int, int, int, int, int, int]: layer = im.layer return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] @@ -917,24 +919,25 @@ class TestFileJpeg: with Image.open("Tests/images/icc-after-SOF.jpg") as im: assert im.info["icc_profile"] == b"profile" - def test_jpeg_magic_number(self) -> None: + def test_jpeg_magic_number(self, monkeypatch: pytest.MonkeyPatch) -> None: size = 4097 buffer = BytesIO(b"\xFF" * size) # Many xFF bytes - buffer.max_pos = 0 + max_pos = 0 orig_read = buffer.read - def read(n=-1): + def read(n: int | None = -1) -> bytes: + nonlocal max_pos res = orig_read(n) - buffer.max_pos = max(buffer.max_pos, buffer.tell()) + max_pos = max(max_pos, buffer.tell()) return res - buffer.read = read + monkeypatch.setattr(buffer, "read", read) with pytest.raises(UnidentifiedImageError): with Image.open(buffer): pass # Assert the entire file has not been read - assert 0 < buffer.max_pos < size + assert 0 < max_pos < size def test_getxmp(self) -> None: with Image.open("Tests/images/xmp_test.jpg") as im: diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 8821fb46a..06591a29a 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -113,7 +113,7 @@ class TestFileTiff: outfile = str(tmp_path / "temp.tif") im.save(outfile, save_all=True, append_images=[im], tiffinfo=im.tag_v2) - def test_seek_too_large(self): + def test_seek_too_large(self) -> None: with pytest.raises(ValueError, match="Unable to seek to frame"): Image.open("Tests/images/seek_too_large.tif") diff --git a/Tests/test_image_mode.py b/Tests/test_image_mode.py index 8e94aafc5..20d3a160e 100644 --- a/Tests/test_image_mode.py +++ b/Tests/test_image_mode.py @@ -68,7 +68,11 @@ def test_sanity() -> None: ), ) def test_properties( - mode, expected_base, expected_type, expected_bands, expected_band_names + mode: str, + expected_base: str, + expected_type: str, + expected_bands: int, + expected_band_names: tuple[str, ...], ) -> None: assert Image.getmodebase(mode) == expected_base assert Image.getmodetype(mode) == expected_type diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 2daaf5c3c..2d461d985 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -98,7 +98,7 @@ def test_quantize_dither_diff() -> None: @pytest.mark.parametrize( "method", (Image.Quantize.MEDIANCUT, Image.Quantize.MAXCOVERAGE) ) -def test_quantize_kmeans(method) -> None: +def test_quantize_kmeans(method: Image.Quantize) -> None: im = hopper() no_kmeans = im.quantize(kmeans=0, method=method) kmeans = im.quantize(kmeans=1, method=method) diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index f6609a1a0..6771b46b0 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -56,10 +56,12 @@ def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) -> @pytest.mark.parametrize( "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) ) -def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: +def test_args_factor_error( + size: float | tuple[int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(size) + im.reduce(size) # type: ignore[arg-type] @pytest.mark.parametrize( @@ -86,10 +88,12 @@ def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) -> ((5, 0, 5, 10), ValueError), ), ) -def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: +def test_args_box_error( + size: str | tuple[int, int, int, int], expected_error: type[Exception] +) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): - im.reduce(2, size).size + im.reduce(2, size).size # type: ignore[arg-type] @pytest.mark.parametrize("mode", ("P", "1", "I;16")) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 1593eaaf7..1606d8939 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -16,7 +16,7 @@ from .helper import ( def test_sanity() -> None: im = hopper() - assert im.thumbnail((100, 100)) is None + assert im.thumbnail((100, 100)) is None # type: ignore[func-returns-value] assert im.size == (100, 100) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index c221fe008..51543d785 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1562,7 +1562,11 @@ def test_compute_regular_polygon_vertices( ], ) def test_compute_regular_polygon_vertices_input_error_handling( - n_sides, bounding_circle, rotation, expected_error, error_message + n_sides: int, + bounding_circle: int | tuple[int | tuple[int] | str, ...], + rotation: int | str, + expected_error: type[Exception], + error_message: str, ) -> None: with pytest.raises(expected_error) as e: ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 4398f8a30..73cad513e 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -224,7 +224,7 @@ def test_render_multiline(font: ImageFont.FreeTypeFont) -> None: draw = ImageDraw.Draw(im) line_spacing = font.getbbox("A")[3] + 4 lines = TEST_TEXT.split("\n") - y = 0 + y: float = 0 for line in lines: draw.text((0, y), line, font=font) y += line_spacing diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index d6bdaf450..27a6090c5 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -454,7 +454,7 @@ def test_autocontrast_cutoff() -> None: # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - def autocontrast(cutoff: int | tuple[int, int]): + def autocontrast(cutoff: int | tuple[int, int]) -> list[int]: return ImageOps.autocontrast(img, cutoff).histogram() assert autocontrast(10) == autocontrast((10, 10)) diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index f59ee7284..e6c312a0c 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -70,7 +70,7 @@ if is_win32(): ] CreateDIBSection.restype = ctypes.wintypes.HBITMAP - def serialize_dib(bi, pixels) -> bytearray: + def serialize_dib(bi: BITMAPINFOHEADER, pixels: ctypes.c_void_p) -> bytearray: bf = BITMAPFILEHEADER() bf.bfType = 0x4D42 bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize diff --git a/Tests/test_main.py b/Tests/test_main.py index e9e12b24a..2582dbee3 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -11,7 +11,7 @@ import pytest "args, report", ((["PIL"], False), (["PIL", "--report"], True), (["PIL.report"], True)), ) -def test_main(args, report) -> None: +def test_main(args: list[str], report: bool) -> None: args = [sys.executable, "-m"] + args out = subprocess.check_output(args).decode("utf-8") lines = out.splitlines() From de0779eee876ae92c48458d38778a3c557566312 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 9 Jun 2024 18:09:54 +1000 Subject: [PATCH 40/91] Removed return value assertion --- Tests/test_image_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 1606d8939..bdbf09c40 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -16,7 +16,7 @@ from .helper import ( def test_sanity() -> None: im = hopper() - assert im.thumbnail((100, 100)) is None # type: ignore[func-returns-value] + im.thumbnail((100, 100)) assert im.size == (100, 100) From 56c79b6f523d2bb7733b9193e47fab2f63f5b546 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 9 Jun 2024 22:13:01 +1000 Subject: [PATCH 41/91] Simplified code --- src/PIL/GimpGradientFile.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/PIL/GimpGradientFile.py b/src/PIL/GimpGradientFile.py index 92068b904..220eac57e 100644 --- a/src/PIL/GimpGradientFile.py +++ b/src/PIL/GimpGradientFile.py @@ -128,16 +128,7 @@ class GimpGradientFile(GradientFile): count = int(line) - gradient: list[ - tuple[ - float, - float, - float, - list[float], - list[float], - Callable[[float, float], float], - ] - ] = [] + self.gradient = [] for i in range(count): s = fp.readline().split() @@ -155,6 +146,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)) From e225f9f589e07d46ad5d1f79e7c233addf9c3836 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Jun 2024 11:50:13 +1000 Subject: [PATCH 42/91] Deprecate ImageDraw.getdraw hints argument --- Tests/test_imagedraw.py | 5 +++++ docs/deprecations.rst | 7 +++++++ docs/releasenotes/10.4.0.rst | 5 +++++ src/PIL/ImageDraw.py | 24 ++++++++---------------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index c221fe008..61d7b5c6a 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1624,3 +1624,8 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rectangle(xy) with pytest.raises(ValueError): draw.rounded_rectangle(xy) + + +def test_getdraw(): + with pytest.warns(DeprecationWarning): + ImageDraw.getdraw(None, []) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index b2cd968fe..8a03d858c 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -115,6 +115,13 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints argument +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. deprecated:: 10.4.0 + +The ``hints`` argument in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + Removed features ---------------- diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index e0d695a8b..8c49e0842 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -34,6 +34,11 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. +ImageDraw.getdraw hints argument +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``hints`` argument in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. + API Changes =========== diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 73ed3d4a9..ec15b535f 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -37,6 +37,7 @@ import struct from typing import TYPE_CHECKING, AnyStr, Sequence, cast from . import Image, ImageColor +from ._deprecate import deprecate from ._typing import Coords """ @@ -902,26 +903,17 @@ except AttributeError: def getdraw(im=None, hints=None): """ - (Experimental) A more advanced 2D drawing interface for PIL images, - based on the WCK interface. - :param im: The image to draw in. - :param hints: An optional list of hints. + :param hints: An optional list of hints. Deprecated. :returns: A (drawing context, drawing resource factory) tuple. """ - # FIXME: this needs more work! - # FIXME: come up with a better 'hints' scheme. - handler = None - if not hints or "nicest" in hints: - try: - from . import _imagingagg as handler - except ImportError: - pass - if handler is None: - from . import ImageDraw2 as handler + if hints is not None: + deprecate("'hints' argument", 12) + from . import ImageDraw2 + if im: - im = handler.Draw(im) - return im, handler + im = ImageDraw2.Draw(im) + return im, ImageDraw2 def floodfill( From 2d1fe7572f461e13ed70f4f6162d5266d8440df0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Jun 2024 14:15:28 +1000 Subject: [PATCH 43/91] Added type hints --- src/PIL/BlpImagePlugin.py | 15 ++++++++++----- src/PIL/BmpImagePlugin.py | 4 ++-- src/PIL/BufrStubImagePlugin.py | 2 +- src/PIL/DdsImagePlugin.py | 2 +- src/PIL/GifImagePlugin.py | 6 +++--- src/PIL/GribStubImagePlugin.py | 2 +- src/PIL/Hdf5StubImagePlugin.py | 2 +- src/PIL/IcnsImagePlugin.py | 19 +++++++++---------- src/PIL/IcoImagePlugin.py | 2 +- src/PIL/ImImagePlugin.py | 4 +++- src/PIL/Image.py | 26 +++++++++++++++++--------- src/PIL/ImageDraw.py | 16 ++++++++++------ src/PIL/ImageFile.py | 2 +- src/PIL/Jpeg2KImagePlugin.py | 6 ++++-- src/PIL/JpegImagePlugin.py | 6 +++--- src/PIL/MpoImagePlugin.py | 2 +- src/PIL/MspImagePlugin.py | 2 +- src/PIL/PcxImagePlugin.py | 2 +- src/PIL/PdfImagePlugin.py | 2 +- src/PIL/PdfParser.py | 13 ++++++------- src/PIL/PngImagePlugin.py | 2 +- src/PIL/PpmImagePlugin.py | 2 +- src/PIL/QoiImagePlugin.py | 16 ++++++++++------ src/PIL/SgiImagePlugin.py | 7 ++++--- src/PIL/SpiderImagePlugin.py | 7 ++++--- src/PIL/TgaImagePlugin.py | 2 +- src/PIL/TiffImagePlugin.py | 4 ++-- src/PIL/WebPImagePlugin.py | 8 ++++---- src/PIL/WmfImagePlugin.py | 2 +- src/PIL/XbmImagePlugin.py | 2 +- 30 files changed, 106 insertions(+), 81 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 2db115ccc..003fa9b24 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -31,6 +31,7 @@ BLP files come in many different flavours: from __future__ import annotations +import abc import os import struct from enum import IntEnum @@ -276,7 +277,7 @@ class BlpImageFile(ImageFile.ImageFile): class _BLPBaseDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: try: self._read_blp_header() self._load() @@ -285,6 +286,10 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): raise OSError(msg) from e return -1, 0 + @abc.abstractmethod + def _load(self) -> None: + pass + def _read_blp_header(self) -> None: assert self.fd is not None self.fd.seek(4) @@ -318,7 +323,7 @@ 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]]) -> bytearray: data = bytearray() _data = BytesIO(self._safe_read(self._blp_lengths[0])) while True: @@ -327,7 +332,7 @@ class _BLPBaseDecoder(ImageFile.PyDecoder): except struct.error: break b, g, r, a = palette[offset] - d = (r, g, b) + d: tuple[int, ...] = (r, g, b) if self._blp_alpha_depth: d += (a,) data.extend(d) @@ -431,7 +436,7 @@ 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) @@ -449,7 +454,7 @@ class BLPEncoder(ImageFile.PyEncoder): return len(data), 0, data -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode != "P": msg = "Unsupported BLP image mode" raise ValueError(msg) diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 2df1d8d33..45c1ea941 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -395,12 +395,12 @@ SAVE = { } -def _dib_save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, False) def _save( - im: Image.Image, fp: IO[bytes], filename: str, bitmap_header: bool = True + im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True ) -> None: try: rawmode, bits, colors = SAVE[im.mode] diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 7388a2b8a..0ee2f653b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -60,7 +60,7 @@ class BufrStubImageFile(ImageFile.StubImageFile): return _handler -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index a3efadb03..861a1eca0 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -511,7 +511,7 @@ class DdsRgbDecoder(ImageFile.PyDecoder): return -1, 0 -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index f41bc2b32..a540595b8 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -715,12 +715,12 @@ def _write_multiple_frames(im, fp, palette): return True -def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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, save_all: bool = False + 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: @@ -796,7 +796,7 @@ def _write_local_header(fp, im, offset, flags): fp.write(o8(8)) # bits -def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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. # diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index d3655f4dd..e9aa084b2 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -60,7 +60,7 @@ class GribStubImageFile(ImageFile.StubImageFile): return _handler -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index b789c215f..cc9e73deb 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -60,7 +60,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile): return _handler -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 0a86ba883..2a89d498c 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -22,6 +22,7 @@ import io import os import struct import sys +from typing import IO from . import Image, ImageFile, PngImagePlugin, features @@ -312,7 +313,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. @@ -346,29 +347,27 @@ 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() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index af94e5a2e..227fcf35c 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -40,7 +40,7 @@ from ._binary import o32le as o32 _MAGIC = b"\0\0\1\0" -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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( diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index c98cfb098..015c2febe 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -326,7 +326,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: image_type, rawmode = SAVE[im.mode] except KeyError as e: @@ -341,6 +341,8 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: # 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]) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index f6ffac8a7..af1748610 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -626,7 +626,7 @@ class Image: self.load() def _dump( - self, file: str | None = None, format: str | None = None, **options + self, file: str | None = None, format: str | None = None, **options: Any ) -> str: suffix = "" if format: @@ -649,10 +649,12 @@ class Image: return filename - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return False + assert isinstance(other, Image) return ( - self.__class__ is other.__class__ - and self.mode == other.mode + self.mode == other.mode and self.size == other.size and self.info == other.info and self.getpalette() == other.getpalette() @@ -2965,7 +2967,7 @@ class ImageTransformHandler: # Debugging -def _wedge(): +def _wedge() -> Image: """Create grayscale wedge (for debugging only)""" return Image()._new(core.wedge("L")) @@ -3566,7 +3568,9 @@ def register_mime(id: str, mimetype: str) -> None: MIME[id.upper()] = mimetype -def register_save(id: str, driver) -> None: +def register_save( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3577,7 +3581,9 @@ def register_save(id: str, driver) -> None: SAVE[id.upper()] = driver -def register_save_all(id: str, driver) -> None: +def register_save_all( + id: str, driver: Callable[[Image, IO[bytes], str | bytes], None] +) -> None: """ Registers an image function to save all the frames of a multiframe format. This function should not be @@ -3651,7 +3657,7 @@ def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: # Simple display support. -def _show(image, **options) -> None: +def _show(image: Image, **options: Any) -> None: from . import ImageShow ImageShow.show(image, **options) @@ -3661,7 +3667,9 @@ def _show(image, **options) -> None: # Effects -def effect_mandelbrot(size, extent, quality): +def effect_mandelbrot( + size: tuple[int, int], extent: tuple[int, int, int, int], quality: int +) -> Image: """ Generate a Mandelbrot set covering the given extent. diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 73ed3d4a9..01f99c119 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -219,7 +219,9 @@ class ImageDraw: # This is a straight line, so no joint is required continue - def coord_at_angle(coord, angle): + def coord_at_angle( + coord: Sequence[float], angle: float + ) -> tuple[float, float]: x, y = coord angle -= 90 distance = width / 2 - 1 @@ -1109,11 +1111,13 @@ def _compute_regular_polygon_vertices( return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2: float | tuple[int, ...]) -> float: +def _color_diff( + color1: float | tuple[int, ...], color2: float | tuple[int, ...] +) -> float: """ Uses 1-norm distance to calculate difference between two values. """ - if isinstance(color2, tuple): - return sum(abs(color1[i] - color2[i]) for i in range(0, len(color2))) - else: - return abs(color1 - color2) + first = color1 if isinstance(color1, tuple) else (color1,) + second = color2 if isinstance(color2, tuple) else (color2,) + + return sum(abs(first[i] - second[i]) for i in range(0, len(second))) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index f0e492387..6bef681e9 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -763,7 +763,7 @@ class PyEncoder(PyCodec): def pushes_fd(self): return self._pushes_fd - def encode(self, bufsize): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: """ Override to perform the encoding process. diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 5a0ef0d01..72c2cb85e 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -329,11 +329,13 @@ def _accept(prefix: bytes) -> bool: # Save support -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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" diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 4d0b75e77..0c8a67888 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -42,7 +42,7 @@ import subprocess import sys import tempfile import warnings -from typing import Any +from typing import IO, Any from . import Image, ImageFile from ._binary import i16be as i16 @@ -644,7 +644,7 @@ def get_sampling(im): 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) @@ -827,7 +827,7 @@ def _save(im, fp, filename): ImageFile._save(im, fp, [("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize) -def _save_cjpeg(im, fp, filename): +def _save_cjpeg(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # ALTERNATIVE: handle JPEGs via the IJG command line utilities. tempfile = im._dump() subprocess.check_call(["cjpeg", "-outfile", filename, tempfile]) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 6716722f2..152e19e23 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -33,7 +33,7 @@ from . import ( from ._binary import o32le -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: JpegImagePlugin._save(im, fp, filename) diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 65cc70624..0a75c868b 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -164,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder) # write MSP files (uncompressed only) -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 026bfd9a0..dd42003b5 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -144,7 +144,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index ccd28f343..f0da1e047 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -40,7 +40,7 @@ from . import Image, ImageFile, ImageSequence, PdfParser, __version__, features # 5. page contents -def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index a6c24e671..52e835801 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -76,7 +76,7 @@ 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) @@ -93,12 +93,11 @@ class IndirectReference(IndirectReferenceTuple): 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): return not (self == other) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 76ffdef3f..9aaadb47d 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -1234,7 +1234,7 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images) seq_num = fdat_chunks.seq_num -def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _save(im, fp, filename, save_all=True) diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 94bf430b8..16c9ccbba 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -328,7 +328,7 @@ class PpmDecoder(ImageFile.PyDecoder): # -------------------------------------------------------------------- -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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": diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index f2cf06d0d..202ef52d0 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -37,6 +37,8 @@ class QoiImageFile(ImageFile.ImageFile): class QoiDecoder(ImageFile.PyDecoder): _pulls_fd = True + _previous_pixel: bytes | bytearray | None = None + _previously_seen_pixels: dict[int, bytes | bytearray] = {} def _add_to_previous_pixels(self, value: bytes | bytearray) -> None: self._previous_pixel = value @@ -45,9 +47,10 @@ class QoiDecoder(ImageFile.PyDecoder): 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) -> tuple[int, int]: + assert self.fd is not None + self._previously_seen_pixels = {} - self._previous_pixel = None self._add_to_previous_pixels(bytearray((0, 0, 0, 255))) data = bytearray() @@ -55,7 +58,8 @@ class QoiDecoder(ImageFile.PyDecoder): 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: 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) @@ -66,7 +70,7 @@ class QoiDecoder(ImageFile.PyDecoder): value = self._previously_seen_pixels.get( op_index, bytearray((0, 0, 0, 0)) ) - elif op == 1: # QOI_OP_DIFF + elif op == 1 and self._previous_pixel: # QOI_OP_DIFF value = bytearray( ( (self._previous_pixel[0] + ((byte & 0b00110000) >> 4) - 2) @@ -77,7 +81,7 @@ class QoiDecoder(ImageFile.PyDecoder): self._previous_pixel[3], ) ) - elif op == 2: # QOI_OP_LUMA + 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 @@ -90,7 +94,7 @@ class QoiDecoder(ImageFile.PyDecoder): ) ) value += self._previous_pixel[3:] - elif op == 3: # QOI_OP_RUN + elif op == 3 and self._previous_pixel: # QOI_OP_RUN run_length = (byte & 0b00111111) + 1 value = self._previous_pixel if bands == 3: diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index 7bd84ebd4..50d979109 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -125,7 +125,7 @@ class SgiImageFile(ImageFile.ImageFile): ] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) @@ -171,8 +171,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - filename = os.path.basename(filename) - img_name = os.path.splitext(filename)[0].encode("ascii", "ignore") + img_name = os.path.splitext(os.path.basename(filename))[0] + if isinstance(img_name, str): + img_name = img_name.encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 fp.write(struct.pack(">h", magic_number)) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 98dd91c0e..a6cc00019 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -263,7 +263,7 @@ def makeSpiderHeader(im: Image.Image) -> list[bytes]: return [struct.pack("f", v) for v in hdr] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode[0] != "F": im = im.convert("F") @@ -279,9 +279,10 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) -def _save_spider(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 401a83f9f..f16f075df 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -178,7 +178,7 @@ SAVE = { } -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 0b9601755..08ee506b1 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -387,7 +387,7 @@ class IFDRational(Rational): def __hash__(self): return self._val.__hash__() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: val = self._val if isinstance(other, IFDRational): other = other._val @@ -2149,7 +2149,7 @@ class AppendingTiffWriter: self.rewriteLastLong(offset) -def _save_all(im: Image.Image, fp: IO[bytes], filename: str) -> None: +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() encoderconfig = im.encoderconfig append_images = list(encoderinfo.get("append_images", [])) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 463d6a623..97debc2ed 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -1,7 +1,7 @@ from __future__ import annotations from io import BytesIO -from typing import Any +from typing import IO, Any from . import Image, ImageFile @@ -182,7 +182,7 @@ class WebPImageFile(ImageFile.ImageFile): return self.__logical_frame -def _save_all(im, fp, filename): +def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: encoderinfo = im.encoderinfo.copy() append_images = list(encoderinfo.get("append_images", [])) @@ -195,7 +195,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: @@ -325,7 +325,7 @@ 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) diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index a68f705a0..3d5cddcc8 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -163,7 +163,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): return super().load() -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index eee727436..6d11bbfcf 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -70,7 +70,7 @@ class XbmImageFile(ImageFile.ImageFile): self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] -def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: +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) From 9f831317fe98633214ad0266417d349b44e5d6bf Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:47:18 +1000 Subject: [PATCH 44/91] Updated text Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- docs/deprecations.rst | 6 +++--- docs/releasenotes/10.4.0.rst | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 8a03d858c..627672e1f 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -115,12 +115,12 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. -ImageDraw.getdraw hints argument -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. deprecated:: 10.4.0 -The ``hints`` argument in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. Removed features ---------------- diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 8c49e0842..44727efd4 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -34,10 +34,10 @@ Support for LibTIFF earlier than 4 Support for LibTIFF earlier than version 4 has been deprecated. Upgrade to a newer version of LibTIFF instead. -ImageDraw.getdraw hints argument -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +ImageDraw.getdraw hints parameter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The ``hints`` argument in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. +The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated. API Changes =========== From 4679e4bf9e542ffae8c81e68603d5c944108389f Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:47:52 +1000 Subject: [PATCH 45/91] Updated deprecation warning --- src/PIL/ImageDraw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index ec15b535f..23d7e6973 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -908,7 +908,7 @@ def getdraw(im=None, hints=None): :returns: A (drawing context, drawing resource factory) tuple. """ if hints is not None: - deprecate("'hints' argument", 12) + deprecate("'hints' parameter", 12) from . import ImageDraw2 if im: From 8e8ee1e4c4b8037c9b755e2ba26a7297dfa5d6ac Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Jun 2024 17:38:17 +1000 Subject: [PATCH 46/91] Accept 't' suffix for libtiff version --- Tests/test_features.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tests/test_features.py b/Tests/test_features.py index de418115e..b7eefa09a 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -38,7 +38,9 @@ def test_version() -> None: assert function(name) == version if name != "PIL": if name == "zlib" and version is not None: - version = version.replace(".zlib-ng", "") + version = re.sub(".zlib-ng$", "", version) + elif name == "libtiff" and version is not None: + version = re.sub("t$", "", version) assert version is None or re.search(r"\d+(\.\d+)*$", version) for module in features.modules: From 9afe9d2769d9241385a4fd8f8e2376fbdca74981 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Jun 2024 16:08:06 +1000 Subject: [PATCH 47/91] Added type hints --- Tests/test_file_gif.py | 9 +- src/PIL/GifImagePlugin.py | 204 +++++++++++++++++++++++--------------- src/PIL/Image.py | 20 ++-- src/PIL/ImageOps.py | 2 +- src/PIL/ImagePalette.py | 13 ++- 5 files changed, 153 insertions(+), 95 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 4e790926b..e19c88a47 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -53,6 +53,7 @@ def test_closed_file() -> None: def test_seek_after_close() -> None: im = Image.open("Tests/images/iss634.gif") + assert isinstance(im, GifImagePlugin.GifImageFile) im.load() im.close() @@ -377,7 +378,8 @@ def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: img = img.convert("RGB") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("RGB"), 0) @@ -388,7 +390,8 @@ def test_save_netpbm_l_mode(tmp_path: Path) -> None: img = img.convert("L") tempfile = str(tmp_path / "temp.gif") - GifImagePlugin._save_netpbm(img, 0, tempfile) + b = BytesIO() + GifImagePlugin._save_netpbm(img, b, tempfile) with Image.open(tempfile) as reloaded: assert_image_similar(img, reloaded.convert("L"), 0) @@ -648,7 +651,7 @@ def test_dispose2_palette(tmp_path: Path) -> None: assert rgb_img.getpixel((50, 50)) == circle # Check that frame transparency wasn't added unnecessarily - assert img._frame_transparency is None + assert getattr(img, "_frame_transparency") is None def test_dispose2_diff(tmp_path: Path) -> None: diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index a540595b8..a305e8de6 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -29,9 +29,10 @@ import itertools import math import os import subprocess +import sys from enum import IntEnum from functools import cached_property -from typing import IO +from typing import IO, TYPE_CHECKING, Any, List, Literal, NamedTuple, Union from . import ( Image, @@ -46,6 +47,9 @@ from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +if TYPE_CHECKING: + from . import _imaging + class LoadingStrategy(IntEnum): """.. versionadded:: 9.1.0""" @@ -118,7 +122,7 @@ class GifImageFile(ImageFile.ImageFile): 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: @@ -163,11 +167,11 @@ 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 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 @@ -195,9 +199,9 @@ class GifImageFile(ImageFile.ImageFile): 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 @@ -213,7 +217,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 # @@ -249,14 +253,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": 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 @@ -345,51 +349,52 @@ class GifImageFile(ImageFile.ImageFile): else: return (color, color, color) + self.dispose = None 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 + 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 @@ -498,7 +503,12 @@ def _normalize_mode(im: Image.Image) -> Image.Image: return im.convert("L") -def _normalize_palette(im, palette, info): +_Palette = Union[bytes, bytearray, List[int], ImagePalette.ImagePalette] + + +def _normalize_palette( + im: Image.Image, palette: _Palette | None, info: dict[str, Any] +) -> Image.Image: """ Normalizes the palette for image. - Sets the palette to the incoming palette, if provided. @@ -526,8 +536,10 @@ def _normalize_palette(im, palette, info): source_palette = bytearray(i // 3 for i in range(768)) im.palette = ImagePalette.ImagePalette("RGB", palette=source_palette) + used_palette_colors: list[int] | None if palette: used_palette_colors = [] + assert source_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) @@ -561,7 +573,7 @@ def _normalize_palette(im, palette, info): def _write_single_frame( im: Image.Image, fp: IO[bytes], - palette: bytes | bytearray | list[int] | ImagePalette.ImagePalette, + palette: _Palette | None, ) -> None: im_out = _normalize_mode(im) for k, v in im_out.info.items(): @@ -585,7 +597,7 @@ def _write_single_frame( def _getbbox( base_im: Image.Image, im_frame: Image.Image -) -> tuple[Image.Image, tuple[int, int, int, int]]: +) -> tuple[Image.Image, tuple[int, int, int, int] | None]: if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im): im_frame = im_frame.convert("RGBA") base_im = base_im.convert("RGBA") @@ -593,12 +605,20 @@ def _getbbox( 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 = [] - previous_im = None + 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", [])): @@ -624,24 +644,22 @@ def _write_multiple_frames(im, fp, palette): frame_count += 1 diff_frame = None - if im_frames: + if im_frames and previous_im: # delta frame delta, bbox = _getbbox(previous_im, im_frame) if not bbox: # This frame is identical to the previous frame if encoderinfo.get("duration"): - im_frames[-1]["encoderinfo"]["duration"] += encoderinfo[ - "duration" - ] + im_frames[-1].encoderinfo["duration"] += encoderinfo["duration"] continue - if im_frames[-1]["encoderinfo"].get("disposal") == 2: + if im_frames[-1].encoderinfo.get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) ) background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) - background_im.putpalette(im_frames[0]["im"].palette) + background_im.putpalette(im_frames[0].im.palette) bbox = _getbbox(background_im, im_frame)[1] elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: @@ -687,31 +705,29 @@ def _write_multiple_frames(im, fp, palette): else: bbox = None previous_im = im_frame - im_frames.append( - {"im": diff_frame or im_frame, "bbox": bbox, "encoderinfo": encoderinfo} - ) + im_frames.append(_Frame(diff_frame or im_frame, bbox, encoderinfo)) if len(im_frames) == 1: if "duration" in im.encoderinfo: # Since multiple frames will not be written, use the combined duration - im.encoderinfo["duration"] = im_frames[0]["encoderinfo"]["duration"] - return + im.encoderinfo["duration"] = im_frames[0].encoderinfo["duration"] + return False for frame_data in im_frames: - im_frame = frame_data["im"] - if not frame_data["bbox"]: + im_frame = frame_data.im + if not frame_data.bbox: # global header - for s in _get_global_header(im_frame, frame_data["encoderinfo"]): + 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 + frame_data.encoderinfo["include_color_table"] = True - im_frame = im_frame.crop(frame_data["bbox"]) - offset = frame_data["bbox"][:2] - _write_frame_data(fp, im_frame, offset, frame_data["encoderinfo"]) + 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 @@ -748,7 +764,9 @@ def get_interlace(im: Image.Image) -> int: return interlace -def _write_local_header(fp, im, offset, flags): +def _write_local_header( + fp: IO[bytes], im: Image.Image, offset: tuple[int, int], flags: int +) -> None: try: transparency = im.encoderinfo["transparency"] except KeyError: @@ -849,7 +867,7 @@ def _save_netpbm(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: _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. @@ -893,6 +911,7 @@ def _get_optimize(im, info): and current_palette_size > 2 ): return used_palette_colors + return None def _get_color_table_size(palette_bytes: bytes) -> int: @@ -933,7 +952,10 @@ def _get_palette_bytes(im: Image.Image) -> bytes: return im.palette.palette if im.palette else b"" -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): @@ -956,7 +978,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 @@ -1018,7 +1040,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 @@ -1038,7 +1065,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. @@ -1050,11 +1079,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"] @@ -1066,7 +1095,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 @@ -1083,12 +1114,23 @@ 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: bytes) -> None: - self.data.append(data) + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + self.data.append(data) + return len(data) + + else: + + def write(self, data: Any) -> int: + self.data.append(data) + return len(data) im.load() # make sure raster data is available diff --git a/src/PIL/Image.py b/src/PIL/Image.py index af1748610..bdd869ccc 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -41,7 +41,7 @@ import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum from types import ModuleType -from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, cast +from typing import IO, TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, cast # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -1367,7 +1367,7 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int] | None: """ Calculates the bounding box of the non-zero regions in the image. @@ -3029,12 +3029,18 @@ def new( color = ImageColor.getcolor(color, mode) im = Image() - if mode == "P" and isinstance(color, (list, tuple)) and len(color) in [3, 4]: - # RGB or RGBA value for a P image - from . import ImagePalette + if ( + mode == "P" + and isinstance(color, (list, tuple)) + and all(isinstance(i, int) for i in color) + ): + color_ints: tuple[int, ...] = cast(Tuple[int, ...], tuple(color)) + if len(color_ints) == 3 or len(color_ints) == 4: + # RGB or RGBA value for a P image + from . import ImagePalette - im.palette = ImagePalette.ImagePalette() - color = im.palette.getcolor(color) + im.palette = ImagePalette.ImagePalette() + color = im.palette.getcolor(color_ints) return im._new(core.fill(mode, size, color)) diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 33db8fa50..cbe189cc9 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -497,7 +497,7 @@ def expand( color = _color(fill, image.mode) if image.palette: palette = ImagePalette.ImagePalette(palette=image.getpalette()) - if isinstance(color, tuple): + if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4): color = palette.getcolor(color) else: palette = None diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 057ccd1d7..1ff05a3ef 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,10 +18,13 @@ from __future__ import annotations import array -from typing import IO, Sequence +from typing import IO, TYPE_CHECKING, Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile +if TYPE_CHECKING: + from . import Image + class ImagePalette: """ @@ -128,7 +131,11 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None) -> int: + def getcolor( + self, + color: tuple[int, int, int] | tuple[int, int, int, int], + image: Image.Image | None = None, + ) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -163,7 +170,7 @@ class ImagePalette: 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: str | IO[str]) -> None: From 84b284723259f2a7d1e079daea0670e0138c4980 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 11 Jun 2024 07:15:47 +1000 Subject: [PATCH 48/91] Accept 't' suffix for libtiff version --- Tests/test_file_libtiff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 22bcd2856..fe9d017c0 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -54,7 +54,7 @@ class TestFileLibTiff(LibTiffTestCase): def test_version(self) -> None: version = features.version_codec("libtiff") assert version is not None - assert re.search(r"\d+\.\d+\.\d+$", version) + assert re.search(r"\d+\.\d+\.\d+t?$", version) def test_g4_tiff(self, tmp_path: Path) -> None: """Test the ordinary file path load path""" From 474ef6ff8d152effba530f364d7c3c5a0653358f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:01:02 +0000 Subject: [PATCH 49/91] Update dependency cibuildwheel to v2.19.0 --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index 7e257b75c..bf1d1315b 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.18.1 +cibuildwheel==2.19.0 From 780d85b667f3201c02c1563ca7c09581f8871237 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 11 Jun 2024 23:18:11 +1000 Subject: [PATCH 50/91] Update CHANGES.rst [ci skip] --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index dc4016d76..d7231ebea 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Accept 't' suffix for libtiff version #8126, #8129 + [radarhere] + +- Deprecate ImageDraw.getdraw hints parameter #8124 + [radarhere, hugovk] + - Added ImageDraw circle() #8085 [void4, hugovk, radarhere] From 1eb960f7e3e662b489c7cfc2b97b88e5317ffff5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 11 Jun 2024 23:26:00 +1000 Subject: [PATCH 51/91] Added type hints --- src/PIL/BlpImagePlugin.py | 20 ++++++++-------- src/PIL/BmpImagePlugin.py | 3 ++- src/PIL/DdsImagePlugin.py | 3 ++- src/PIL/EpsImagePlugin.py | 45 ++++++++++++++++++------------------ src/PIL/FitsImagePlugin.py | 2 +- src/PIL/FpxImagePlugin.py | 2 +- src/PIL/ImageFile.py | 2 +- src/PIL/Jpeg2KImagePlugin.py | 15 ++++++------ src/PIL/MicImagePlugin.py | 2 +- src/PIL/MpoImagePlugin.py | 15 ++++-------- src/PIL/PSDraw.py | 2 +- src/PIL/PalmImagePlugin.py | 10 ++++---- src/PIL/PdfParser.py | 3 +-- src/PIL/PngImagePlugin.py | 2 +- src/PIL/TarIO.py | 8 +------ src/PIL/TiffImagePlugin.py | 29 +++++++++++------------ 16 files changed, 78 insertions(+), 85 deletions(-) diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 003fa9b24..59246c6e2 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -61,7 +61,9 @@ 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) """ @@ -69,9 +71,9 @@ def decode_dxt1(data, alpha=False): blocks = len(data) // 8 # number of blocks in row ret = (bytearray(), bytearray(), bytearray(), bytearray()) - for block in range(blocks): + for block_index in range(blocks): # Decode next 8-byte block. - idx = block * 8 + idx = block_index * 8 color0, color1, bits = struct.unpack_from(" tuple[bytearray, bytearray, bytearray, bytearray]: """ input: one "row" of data (i.e. will produce 4*width pixels) """ @@ -124,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) @@ -169,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) """ @@ -177,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(" tuple[int, int]: + assert self.fd is not None rle4 = self.args[1] data = bytearray() x = 0 diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 861a1eca0..e74727007 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -480,7 +480,8 @@ class DdsImageFile(ImageFile.ImageFile): class DdsRgbDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> 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 diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index d24a2ba80..380b1cf0e 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -27,6 +27,7 @@ import re import subprocess import sys import tempfile +from typing import IO from . import Image, ImageFile from ._binary import i32le as i32 @@ -236,7 +237,7 @@ class EpsImageFile(ImageFile.ImageFile): msg = 'EPS header missing "%%BoundingBox" comment' raise SyntaxError(msg) - def _read_comment(s): + def _read_comment(s: str) -> bool: nonlocal reading_trailer_comments try: m = split.match(s) @@ -244,27 +245,25 @@ class EpsImageFile(ImageFile.ImageFile): 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 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 while True: byte = self.fp.read(1) @@ -413,7 +412,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 diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 071918925..a169b6083 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -122,7 +122,7 @@ class FitsImageFile(ImageFile.ImageFile): class FitsGzipDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: assert self.fd is not None value = gzip.decompress(self.fd.read()) diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index b3e6c6e36..c1927bd26 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -241,7 +241,7 @@ class FpxImageFile(ImageFile.ImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.ole.close() super().__exit__() diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 6bef681e9..5d67409ea 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -487,7 +487,7 @@ class Parser: def __enter__(self): return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self): diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 72c2cb85e..60f3bff0a 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -18,7 +18,7 @@ from __future__ import annotations import io import os import struct -from typing import IO +from typing import IO, Tuple, cast from . import Image, ImageFile, ImagePalette, _binary @@ -59,7 +59,7 @@ 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) @@ -82,9 +82,9 @@ class BoxReader: 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 @@ -127,12 +127,13 @@ def _parse_codestream(fp): 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): diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 5aef94dfb..ed2ea2849 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -93,7 +93,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.ole.close() super().close() - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.__fp.close() self.ole.close() super().__exit__() diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index 152e19e23..f21570661 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -37,19 +37,14 @@ 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 = [] + offsets: list[int] = [] for imSequence in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(imSequence): if not offsets: diff --git a/src/PIL/PSDraw.py b/src/PIL/PSDraw.py index 4e2b9788e..673eae1d1 100644 --- a/src/PIL/PSDraw.py +++ b/src/PIL/PSDraw.py @@ -138,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") diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index 85f9fe1bf..fc83918b5 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -8,6 +8,8 @@ ## from __future__ import annotations +from typing import IO + from . import Image, ImageFile from ._binary import o8 from ._binary import o16be as o16b @@ -82,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)) @@ -112,7 +114,7 @@ _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 @@ -141,7 +143,7 @@ def _save(im, fp, filename): raise OSError(msg) # we ignore the palette here - im.mode = "P" + im._mode = "P" rawmode = f"P;{bpp}" version = 1 diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 52e835801..9e2231347 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -404,9 +404,8 @@ class PdfParser: 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) -> None: self.close_buf() diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 9aaadb47d..ba9598065 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -178,7 +178,7 @@ class ChunkStream: def __enter__(self) -> ChunkStream: return self - def __exit__(self, *args): + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index 7470663b4..cba26d4b0 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -16,7 +16,6 @@ from __future__ import annotations import io -from types import TracebackType from . import ContainerIO @@ -61,12 +60,7 @@ class TarIO(ContainerIO.ContainerIO[bytes]): def __enter__(self) -> TarIO: return self - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: + def __exit__(self, *args: object) -> None: self.close() def close(self) -> None: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 08ee506b1..702d8f33b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -717,7 +717,7 @@ class ImageFileDirectory_v2(_IFDv2Base): # Unspec'd, and length > 1 dest[tag] = values - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: self._tags_v2.pop(tag, None) self._tags_v1.pop(tag, None) self._tagdata.pop(tag, None) @@ -1106,7 +1106,7 @@ class TiffImageFile(ImageFile.ImageFile): super().__init__(fp, filename) - def _open(self): + def _open(self) -> None: """Open the first image in a TIFF file""" # Header @@ -1123,8 +1123,8 @@ class TiffImageFile(ImageFile.ImageFile): self.__first = self.__next = self.tag_v2.next self.__frame = -1 self._fp = self.fp - self._frame_pos = [] - self._n_frames = None + self._frame_pos: list[int] = [] + self._n_frames: int | None = None logger.debug("*** TiffImageFile._open ***") logger.debug("- __first: %s", self.__first) @@ -1998,10 +1998,9 @@ class AppendingTiffWriter: def __enter__(self) -> AppendingTiffWriter: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, *args: object) -> None: if self.close_fp: self.close() - return False def tell(self) -> int: return self.f.tell() - self.offsetOfNewPage @@ -2043,42 +2042,42 @@ class AppendingTiffWriter: def write(self, data): return self.f.write(data) - def readShort(self): + def readShort(self) -> int: (value,) = struct.unpack(self.shortFmt, self.f.read(2)) return value - def readLong(self): + def readLong(self) -> int: (value,) = struct.unpack(self.longFmt, self.f.read(4)) return value - def rewriteLastShortToLong(self, value): + def rewriteLastShortToLong(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" raise RuntimeError(msg) - def rewriteLastShort(self, value): + def rewriteLastShort(self, value: int) -> None: self.f.seek(-2, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.shortFmt, value)) if bytes_written is not None and bytes_written != 2: msg = f"wrote only {bytes_written} bytes but wanted 2" raise RuntimeError(msg) - def rewriteLastLong(self, value): + def rewriteLastLong(self, value: int) -> None: self.f.seek(-4, os.SEEK_CUR) bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" raise RuntimeError(msg) - def writeShort(self, value): + def writeShort(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.shortFmt, value)) if bytes_written is not None and bytes_written != 2: msg = f"wrote only {bytes_written} bytes but wanted 2" raise RuntimeError(msg) - def writeLong(self, value): + def writeLong(self, value: int) -> None: bytes_written = self.f.write(struct.pack(self.longFmt, value)) if bytes_written is not None and bytes_written != 4: msg = f"wrote only {bytes_written} bytes but wanted 4" @@ -2097,9 +2096,9 @@ class AppendingTiffWriter: field_size = self.fieldSizes[field_type] total_size = field_size * count is_local = total_size <= 4 + offset: int | None if not is_local: - offset = self.readLong() - offset += self.offsetOfNewPage + offset = self.readLong() + self.offsetOfNewPage self.rewriteLastLong(offset) if tag in self.Tags: From 2e15dc9f534be4411e4eddbf3d85b2034384b6a3 Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:00:08 -0400 Subject: [PATCH 52/91] Improve xdg directory support in Linux --- src/PIL/ImageFont.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index fa5608e6c..163bb7006 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -837,12 +837,19 @@ def truetype( 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") + dirs.append(os.path.join(data_home, "fonts")) + + 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" + dirs += [os.path.join(ddir, "fonts") for ddir in data_dirs.split(":")] elif sys.platform == "darwin": dirs += [ "/Library/Fonts", From 1175e53d53ccd1fa86a94b2f8d2365dbc0a3336a Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:08:53 -0400 Subject: [PATCH 53/91] Set XDG_DATA_HOME on font tests --- Tests/test_imagefont.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 4398f8a30..3cba726d6 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -564,6 +564,7 @@ def test_find_font( # catching syntax like errors monkeypatch.setattr(sys, "platform", platform) if platform == "linux": + monkeypatch.setenv("XDG_DATA_HOME", "/home/__pillow__/.local/share") monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: From be73b13ad33a88a17e8055e74993d02c5d3f68a0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 12 Jun 2024 21:15:55 +1000 Subject: [PATCH 54/91] Added type hints --- src/PIL/BdfFontFile.py | 2 +- src/PIL/ImageDraw.py | 20 ++++++------ src/PIL/ImagePalette.py | 12 +++++--- src/PIL/MicImagePlugin.py | 4 +-- src/PIL/PngImagePlugin.py | 63 +++++++++++++++++++++++++------------- src/PIL/TiffImagePlugin.py | 31 +++++++++++-------- src/PIL/_imaging.pyi | 2 +- src/PIL/features.py | 18 ++++++----- 8 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index e3eda4fe9..bc1416c74 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -103,7 +103,7 @@ def bdf_char( class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp: BinaryIO): + def __init__(self, fp: BinaryIO) -> None: super().__init__() s = fp.readline() diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index e74fab9fb..41a3eb0cb 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,12 +34,16 @@ from __future__ import annotations import math import numbers import struct +from types import ModuleType from typing import TYPE_CHECKING, AnyStr, Sequence, cast from . import Image, ImageColor from ._deprecate import deprecate from ._typing import Coords +if TYPE_CHECKING: + from . import ImageDraw2, ImageFont + """ A simple 2D drawing interface for PIL images.

@@ -93,9 +97,6 @@ class ImageDraw: self.fontmode = "L" # aliasing is okay for other modes self.fill = False - if TYPE_CHECKING: - from . import ImageFont - def getfont( self, ) -> ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont: @@ -879,7 +880,7 @@ class ImageDraw: return bbox -def Draw(im, mode: str | None = None) -> ImageDraw: +def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -891,7 +892,7 @@ def Draw(im, mode: str | None = None) -> ImageDraw: defaults to the mode of the image. """ try: - return im.getdraw(mode) + return getattr(im, "getdraw")(mode) except AttributeError: return ImageDraw(im, mode) @@ -903,7 +904,9 @@ except AttributeError: Outline = None -def getdraw(im=None, hints=None): +def getdraw( + im: Image.Image | None = None, hints: list[str] | None = None +) -> tuple[ImageDraw2.Draw | None, ModuleType]: """ :param im: The image to draw in. :param hints: An optional list of hints. Deprecated. @@ -913,9 +916,8 @@ def getdraw(im=None, hints=None): deprecate("'hints' parameter", 12) from . import ImageDraw2 - if im: - im = ImageDraw2.Draw(im) - return im, ImageDraw2 + draw = ImageDraw2.Draw(im) if im is not None else None + return draw, ImageDraw2 def floodfill( diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 1ff05a3ef..6473c4577 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -54,7 +54,7 @@ class ImagePalette: self._palette = palette @property - def colors(self): + def colors(self) -> dict[tuple[int, int, int] | tuple[int, int, int, int], int]: if self._colors is None: mode_len = len(self.mode) self._colors = {} @@ -66,7 +66,9 @@ class ImagePalette: return self._colors @colors.setter - def colors(self, colors): + def colors( + self, colors: dict[tuple[int, int, int] | tuple[int, int, int, int], int] + ) -> None: self._colors = colors def copy(self) -> ImagePalette: @@ -107,11 +109,13 @@ class ImagePalette: # Declare tostring as an alias for tobytes tostring = tobytes - def _new_color_index(self, image=None, e=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 = () + special_colors: tuple[int | tuple[int, ...] | None, ...] = () if image: special_colors = ( image.info.get("background"), diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index ed2ea2849..07239887f 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -63,7 +63,7 @@ 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 @@ -85,7 +85,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self.frame = frame - def tell(self): + def tell(self) -> int: return self.frame def close(self) -> None: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index ba9598065..927d6c0cf 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -39,7 +39,7 @@ import struct import warnings import zlib from enum import IntEnum -from typing import IO, Any +from typing import IO, TYPE_CHECKING, Any, NoReturn from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence from ._binary import i16be as i16 @@ -48,6 +48,9 @@ from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +if TYPE_CHECKING: + from . import _imaging + logger = logging.getLogger(__name__) is_cid = re.compile(rb"\w\w\w\w").match @@ -249,6 +252,9 @@ class iTXt(str): """ + lang: str | bytes | None + tkey: str | bytes | None + @staticmethod def __new__(cls, text, lang=None, tkey=None): """ @@ -270,10 +276,10 @@ class PngInfo: """ - def __init__(self): - self.chunks = [] + def __init__(self) -> None: + self.chunks: list[tuple[bytes, bytes, bool]] = [] - def add(self, cid, data, after_idat=False): + def add(self, cid: bytes, data: bytes, after_idat: bool = False) -> None: """Appends an arbitrary chunk. Use with caution. :param cid: a byte string, 4 bytes long. @@ -283,12 +289,16 @@ class PngInfo: """ - chunk = [cid, data] - if after_idat: - chunk.append(True) - self.chunks.append(tuple(chunk)) + self.chunks.append((cid, data, after_idat)) - def add_itxt(self, key, value, lang="", tkey="", zip=False): + def add_itxt( + self, + key: str | bytes, + value: str | bytes, + lang: str | bytes = "", + tkey: str | bytes = "", + zip: bool = False, + ) -> None: """Appends an iTXt chunk. :param key: latin-1 encodable text key name @@ -316,7 +326,9 @@ class PngInfo: else: self.add(b"iTXt", key + b"\0\0\0" + lang + b"\0" + tkey + b"\0" + value) - def add_text(self, key, value, zip=False): + def add_text( + self, key: str | bytes, value: str | bytes | iTXt, zip: bool = False + ) -> None: """Appends a text chunk. :param key: latin-1 encodable text key name @@ -326,7 +338,13 @@ class PngInfo: """ if isinstance(value, iTXt): - return self.add_itxt(key, value, value.lang, value.tkey, zip=zip) + return self.add_itxt( + key, + value, + value.lang if value.lang is not None else b"", + value.tkey if value.tkey is not None else b"", + zip=zip, + ) # The tEXt chunk stores latin-1 text if not isinstance(value, bytes): @@ -434,7 +452,7 @@ class PngStream(ChunkStream): raise SyntaxError(msg) return s - def chunk_IDAT(self, pos, length): + def chunk_IDAT(self, pos: int, length: int) -> NoReturn: # image data if "bbox" in self.im_info: tile = [("zip", self.im_info["bbox"], pos, self.im_rawmode)] @@ -447,7 +465,7 @@ class PngStream(ChunkStream): msg = "image data found" raise EOFError(msg) - def chunk_IEND(self, pos, length): + def chunk_IEND(self, pos: int, length: int) -> NoReturn: msg = "end of PNG image" raise EOFError(msg) @@ -821,7 +839,10 @@ class PngImageFile(ImageFile.ImageFile): msg = "no more images in APNG file" raise EOFError(msg) from e - def _seek(self, frame, rewind=False): + def _seek(self, frame: int, rewind: bool = False) -> None: + assert self.png is not None + + self.dispose: _imaging.ImagingCore | None if frame == 0: if rewind: self._fp.seek(self.__rewind) @@ -906,14 +927,14 @@ class PngImageFile(ImageFile.ImageFile): if self._prev_im is None and self.dispose_op == Disposal.OP_PREVIOUS: self.dispose_op = Disposal.OP_BACKGROUND + self.dispose = None if self.dispose_op == Disposal.OP_PREVIOUS: - self.dispose = self._prev_im.copy() - self.dispose = self._crop(self.dispose, self.dispose_extent) + if self._prev_im: + self.dispose = self._prev_im.copy() + self.dispose = self._crop(self.dispose, self.dispose_extent) elif self.dispose_op == Disposal.OP_BACKGROUND: self.dispose = Image.core.fill(self.mode, self.size) self.dispose = self._crop(self.dispose, self.dispose_extent) - else: - self.dispose = None def tell(self) -> int: return self.__frame @@ -1026,7 +1047,7 @@ class PngImageFile(ImageFile.ImageFile): return None return self.getexif()._get_merged_dict() - def getexif(self): + def getexif(self) -> Image.Exif: if "exif" not in self.info: self.load() @@ -1346,7 +1367,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk(fp, cid, data) elif cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if not after_idat: chunk(fp, cid, data) @@ -1425,7 +1446,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): cid, data = info_chunk[:2] if cid[1:2].islower(): # Private chunk - after_idat = info_chunk[2:3] + after_idat = len(info_chunk) == 3 and info_chunk[2] if after_idat: chunk(fp, cid, data) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 702d8f33b..833e12d2b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,7 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational -from typing import IO, TYPE_CHECKING, Any, Callable +from typing import IO, TYPE_CHECKING, Any, Callable, NoReturn from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -384,7 +384,7 @@ class IFDRational(Rational): def __repr__(self) -> str: return str(float(self._val)) - def __hash__(self): + def __hash__(self) -> int: return self._val.__hash__() def __eq__(self, other: object) -> bool: @@ -551,7 +551,12 @@ class ImageFileDirectory_v2(_IFDv2Base): _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} _write_dispatch: dict[int, Callable[..., Any]] = {} - def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): + def __init__( + self, + ifh: bytes = b"II\052\0\0\0\0\0", + prefix: bytes | None = None, + group: int | None = None, + ) -> None: """Initialize an ImageFileDirectory. To construct an ImageFileDirectory from a real file, pass the 8-byte @@ -575,7 +580,7 @@ class ImageFileDirectory_v2(_IFDv2Base): raise SyntaxError(msg) self._bigtiff = ifh[2] == 43 self.group = group - self.tagtype = {} + self.tagtype: dict[int, int] = {} """ Dictionary of tag types """ self.reset() (self.next,) = ( @@ -587,18 +592,18 @@ class ImageFileDirectory_v2(_IFDv2Base): offset = property(lambda self: self._offset) @property - def legacy_api(self): + def legacy_api(self) -> bool: return self._legacy_api @legacy_api.setter - def legacy_api(self, value): + def legacy_api(self, value: bool) -> NoReturn: msg = "Not allowing setting of legacy api" raise Exception(msg) - def reset(self): - self._tags_v1 = {} # will remain empty if legacy_api is false - self._tags_v2 = {} # main tag storage - self._tagdata = {} + def reset(self) -> None: + self._tags_v1: dict[int, Any] = {} # will remain empty if legacy_api is false + self._tags_v2: dict[int, Any] = {} # main tag storage + self._tagdata: dict[int, bytes] = {} self.tagtype = {} # added 2008-06-05 by Florian Hoech self._next = None self._offset = None @@ -2039,7 +2044,7 @@ class AppendingTiffWriter: num_tags = self.readShort() self.f.seek(num_tags * 12, os.SEEK_CUR) - def write(self, data): + def write(self, data: bytes) -> int | None: return self.f.write(data) def readShort(self) -> int: @@ -2122,7 +2127,9 @@ class AppendingTiffWriter: # skip the locally stored value that is not an offset self.f.seek(4, os.SEEK_CUR) - def fixOffsets(self, count, isShort=False, isLong=False): + def fixOffsets( + self, count: int, isShort: bool = False, isLong: bool = False + ) -> None: if not isShort and not isLong: msg = "offset is neither short nor long" raise RuntimeError(msg) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 1fe954417..b233eb34d 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -12,5 +12,5 @@ class ImagingDraw: class PixelAccess: def __getattr__(self, name: str) -> Any: ... -def font(image, glyphdata: bytes) -> ImagingFont: ... +def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/features.py b/src/PIL/features.py index 16c749f14..13908c4eb 100644 --- a/src/PIL/features.py +++ b/src/PIL/features.py @@ -4,6 +4,7 @@ import collections import os import sys import warnings +from typing import IO import PIL @@ -223,7 +224,7 @@ def get_supported() -> list[str]: return ret -def pilinfo(out=None, supported_formats=True): +def pilinfo(out: IO[str] | None = None, supported_formats: bool = True) -> None: """ Prints information about this installation of Pillow. This function can be called with ``python3 -m PIL``. @@ -244,9 +245,9 @@ def pilinfo(out=None, supported_formats=True): print("-" * 68, file=out) print(f"Pillow {PIL.__version__}", file=out) - py_version = sys.version.splitlines() - print(f"Python {py_version[0].strip()}", file=out) - for py_version in py_version[1:]: + py_version_lines = sys.version.splitlines() + print(f"Python {py_version_lines[0].strip()}", file=out) + for py_version in py_version_lines[1:]: print(f" {py_version.strip()}", file=out) print("-" * 68, file=out) print(f"Python executable is {sys.executable or 'unknown'}", file=out) @@ -282,9 +283,12 @@ def pilinfo(out=None, supported_formats=True): ("xcb", "XCB (X protocol)"), ]: if check(name): - if name == "jpg" and check_feature("libjpeg_turbo"): - v = "libjpeg-turbo " + version_feature("libjpeg_turbo") - else: + v: str | None = None + if name == "jpg": + libjpeg_turbo_version = version_feature("libjpeg_turbo") + if libjpeg_turbo_version is not None: + v = "libjpeg-turbo " + libjpeg_turbo_version + if v is None: v = version(name) if v is not None: version_static = name in ("pil", "jpg") From c9a9e81749c12fdc4c7187284fe661810c9bc5c4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 13 Jun 2024 00:03:16 +1000 Subject: [PATCH 55/91] Use latest Ubuntu --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index b83ba05b1..def6282dd 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 formats: [pdf] build: - os: ubuntu-22.04 + os: ubuntu-lts-latest tools: python: "3" jobs: From 00161099c7df3aa76bcbd7106aa144072b45a592 Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:36:14 -0400 Subject: [PATCH 56/91] Update docs for ImageFont.truetype [ci skip] --- src/PIL/ImageFont.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 163bb7006..235a76325 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -775,10 +775,15 @@ def truetype( :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). From ed828d23df58f8e59f69933b74db12d7fba87c8c Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Wed, 12 Jun 2024 13:08:48 -0400 Subject: [PATCH 57/91] Update changes in release notes [ci skip] --- docs/releasenotes/10.4.0.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 44727efd4..0a54cc7a3 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -42,6 +42,14 @@ The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecat API Changes =========== +ImageFont.truetype +^^^^^^^^^^^^^^^^^^ + +:py:function:`~PIL.ImageFont.truetype` now searches for fonts in the directory +indicated by the ``XDG_DATA_HOME`` in addition to those specified in ``XDG_DATA_DIRS``. +Additionally, the default for ``XDG_DATA_DIRS`` has been updated to match the +freedesktop spec. + TODO ^^^^ From 7e14364cee9e5f78d0bb658dbf470a4e7e580e34 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 1 May 2024 22:03:33 +1000 Subject: [PATCH 58/91] putalpha does not allow other color values --- src/PIL/Image.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index da61ef4dd..5416524c0 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1933,8 +1933,7 @@ class Image: The new layer must be either "L" or "1". :param alpha: The new alpha layer. This can either be an "L" or "1" - image having the same size as this image, or an integer or - other color value. + image having the same size as this image, or an integer. """ self._ensure_mutable() From eb56f3ed56c9f72e5579943f9c87a2fcc9846587 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 30 May 2024 23:20:13 +1000 Subject: [PATCH 59/91] Removed ignores --- src/PIL/Image.py | 5 ++--- src/PIL/ImageGrab.py | 5 +++-- src/PIL/PyAccess.py | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 5416524c0..faf70b718 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2074,9 +2074,8 @@ class Image: if self.mode == "PA": alpha = value[3] if len(value) == 4 else 255 value = value[:3] - value = self.palette.getcolor(value, self) - if self.mode == "PA": - value = (value, alpha) # type: ignore[assignment] + palette_index = self.palette.getcolor(value, self) + value = (palette_index, alpha) if self.mode == "PA" else palette_index return self.im.putpixel(xy, value) def remap_palette(self, dest_map, source_palette=None): diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index f16e95544..96a28bb35 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -33,6 +33,7 @@ def grab( all_screens: bool = False, xdisplay: str | None = None, ) -> Image.Image: + im: Image.Image if xdisplay is None: if sys.platform == "darwin": fh, filepath = tempfile.mkstemp(".png") @@ -42,7 +43,7 @@ def grab( left, top, right, bottom = bbox args += ["-R", f"{left},{top},{right-left},{bottom-top}"] subprocess.call(args + ["-x", filepath]) - im: Image.Image = Image.open(filepath) + im = Image.open(filepath) im.load() os.unlink(filepath) if bbox: @@ -84,7 +85,7 @@ def grab( fh, filepath = tempfile.mkstemp(".png") os.close(fh) subprocess.call(["gnome-screenshot", "-f", filepath]) - im: Image.Image = Image.open(filepath) # type: ignore[no-redef, unused-ignore] + im = Image.open(filepath) im.load() os.unlink(filepath) if bbox: diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index cc8e2e359..5663a7e48 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -107,9 +107,8 @@ class PyAccess: 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) # type: ignore[assignment] + palette_index = self._palette.getcolor(color, self._img) + color = (palette_index, alpha) if self._im.mode == "PA" else palette_index return self.set_pixel(x, y, color) From 2a2033eea1bd36637645f211239033bb7fad482d Mon Sep 17 00:00:00 2001 From: Nulano Date: Wed, 12 Jun 2024 21:35:22 +0200 Subject: [PATCH 60/91] mypy fixes after merge --- src/PIL/Image.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 76ea48510..6dee3fe23 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1128,7 +1128,10 @@ class Image: del new_im.info["transparency"] if trns is not None: try: - new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(Tuple[int, int, int], trns), # trns was converted to RGB + new_im, + ) except Exception: # if we can't make a transparent color, don't leave the old # transparency hanging around to mess us up. @@ -1178,7 +1181,10 @@ class Image: if trns is not None: if new_im.mode == "P": try: - new_im.info["transparency"] = new_im.palette.getcolor(trns, new_im) + new_im.info["transparency"] = new_im.palette.getcolor( + cast(Tuple[int, int, int], trns), # trns was converted to RGB + new_im, + ) except ValueError as e: del new_im.info["transparency"] if str(e) != "cannot allocate more than 256 colors": From 238110303c7bf829c0e56e5f721bcd80ad715699 Mon Sep 17 00:00:00 2001 From: Nulano Date: Wed, 12 Jun 2024 22:17:10 +0200 Subject: [PATCH 61/91] getpixel and putpixel also support a list argument --- src/PIL/Image.py | 6 ++++-- src/PIL/PyAccess.py | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6dee3fe23..9c60bbb7a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1676,7 +1676,7 @@ class Image: del self.info["transparency"] def getpixel( - self, xy: tuple[SupportsInt, SupportsInt] + self, xy: tuple[int, int] | list[int] ) -> float | tuple[int, ...] | None: """ Returns the pixel value at a given position. @@ -2058,7 +2058,9 @@ class Image: self.palette.mode = "RGB" self.load() # install new palette - def putpixel(self, xy: tuple[int, int], value: float | tuple[int, ...]) -> None: + def putpixel( + self, xy: tuple[int, int], value: float | tuple[int, ...] | list[int] + ) -> None: """ Modifies the pixel at the given position. The color is given as a single numerical value for single-band images, and a tuple for diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 5663a7e48..d41f00aea 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -77,7 +77,11 @@ class PyAccess: def _post_init(self) -> None: pass - def __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None: + def __setitem__( + self, + xy: tuple[int, int] | list[int], + color: float | tuple[int, ...] | list[int], + ) -> None: """ Modifies the pixel at x,y. The color is given as a single numerical value for single band images, and a tuple for @@ -112,7 +116,7 @@ class PyAccess: return self.set_pixel(x, y, color) - def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: + def __getitem__(self, xy: tuple[int, int] | list[int]) -> float | tuple[int, ...]: """ Returns the pixel at x,y. The pixel is returned as a single value for single band images or a tuple for multiple band From 0a2baab6c1744a42422713858bc2bc2872805794 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:19:17 +0000 Subject: [PATCH 62/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/PIL/Image.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 9c60bbb7a..5619915ea 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -48,7 +48,6 @@ from typing import ( Literal, Protocol, Sequence, - SupportsInt, Tuple, cast, ) From 6cf08afb1587bfb148334907f91ae7dbe4d8a41b Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Thu, 13 Jun 2024 00:03:03 -0400 Subject: [PATCH 63/91] Fix broken reference in release notes [ci skip] Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/releasenotes/10.4.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 0a54cc7a3..36c2758f9 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -45,7 +45,7 @@ API Changes ImageFont.truetype ^^^^^^^^^^^^^^^^^^ -:py:function:`~PIL.ImageFont.truetype` now searches for fonts in the directory +:py:func:`~PIL.ImageFont.truetype` now searches for fonts in the directory indicated by the ``XDG_DATA_HOME`` in addition to those specified in ``XDG_DATA_DIRS``. Additionally, the default for ``XDG_DATA_DIRS`` has been updated to match the freedesktop spec. From 48606afeb6e69483c329535106383c3dd79a323c Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Thu, 13 Jun 2024 00:12:07 -0400 Subject: [PATCH 64/91] Add missing wording for envvar in release notes [ci skip] --- docs/releasenotes/10.4.0.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 36c2758f9..00506a094 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -45,10 +45,10 @@ API Changes ImageFont.truetype ^^^^^^^^^^^^^^^^^^ -:py:func:`~PIL.ImageFont.truetype` now searches for fonts in the directory -indicated by the ``XDG_DATA_HOME`` in addition to those specified in ``XDG_DATA_DIRS``. -Additionally, the default for ``XDG_DATA_DIRS`` has been updated to match the -freedesktop spec. +:py:func:`~PIL.ImageFont.truetype` now searches for fonts in the directory indicated +by the ``XDG_DATA_HOME`` envirnorment variable in addition to those specified in +``XDG_DATA_DIRS``. Additionally, the default for ``XDG_DATA_DIRS`` has been updated +to match the freedesktop spec. TODO ^^^^ From d56ffebf2a18ffd94043300418bcb98819a6a776 Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Thu, 13 Jun 2024 09:43:40 -0400 Subject: [PATCH 65/91] Fix typo in release notes [ci skip] Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- docs/releasenotes/10.4.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 00506a094..1678b460c 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -46,7 +46,7 @@ ImageFont.truetype ^^^^^^^^^^^^^^^^^^ :py:func:`~PIL.ImageFont.truetype` now searches for fonts in the directory indicated -by the ``XDG_DATA_HOME`` envirnorment variable in addition to those specified in +by the ``XDG_DATA_HOME`` environment variable in addition to those specified in ``XDG_DATA_DIRS``. Additionally, the default for ``XDG_DATA_DIRS`` has been updated to match the freedesktop spec. From 20ce7ad9f8c23bf6b02fd0a68cd39b20f12b58b7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 13 Jun 2024 19:36:32 +1000 Subject: [PATCH 66/91] Updated type hint --- src/PIL/PyAccess.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index d41f00aea..3be1ccace 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -148,7 +148,9 @@ class PyAccess: def get_pixel(self, x: int, y: int) -> float | tuple[int, ...]: raise NotImplementedError() - def set_pixel(self, x: int, y: int, color: float | tuple[int, ...]) -> None: + def set_pixel( + self, x: int, y: int, color: float | tuple[int, ...] | list[int] + ) -> None: raise NotImplementedError() From 05a70e7861f37d2eb65be0186345ea8ea62fc3fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 14 Jun 2024 20:59:12 +1000 Subject: [PATCH 67/91] Corrected Ghostscript path --- .appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.appveyor.yml b/.appveyor.yml index 92a04a021..6ce5200b6 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -35,7 +35,7 @@ install: - curl -fsSL -o nasm-win64.zip https://raw.githubusercontent.com/python-pillow/pillow-depends/main/nasm-2.16.03-win64.zip - 7z x nasm-win64.zip -oc:\ - choco install ghostscript --version=10.3.1 -- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.00.0\bin;%PATH% +- path c:\nasm-2.16.03;C:\Program Files\gs\gs10.03.1\bin;%PATH% - cd c:\pillow\winbuild\ - ps: | c:\python38\python.exe c:\pillow\winbuild\build_prepare.py -v --depends=C:\pillow-depends\ From dfd53564ff6a3fc7d35a5884bc0ef03939bcec0a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Jun 2024 11:51:02 +1000 Subject: [PATCH 68/91] Ignore brew dependencies for libraqm on macOS 13 --- .github/workflows/macos-install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index 28124d7f7..f8f191d38 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -7,11 +7,15 @@ brew install \ ghostscript \ libimagequant \ libjpeg \ - libraqm \ libtiff \ little-cms2 \ openjpeg \ webp +if [[ "$ImageOS" == "macos13" ]]; then + brew install --ignore-dependencies libraqm +else + brew install libraqm +fi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" # TODO Update condition when cffi supports 3.13 From ed5e8f91c57a402ba67ee23530084b8e99c0c41e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Jun 2024 19:11:11 +1000 Subject: [PATCH 69/91] Use pkg-config to help find libwebp and raqm --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index abdd87ea2..0abfaaddc 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,9 @@ IMAGEQUANT_ROOT = None JPEG2K_ROOT = None JPEG_ROOT = None LCMS_ROOT = None +RAQM_ROOT = None TIFF_ROOT = None +WEBP_ROOT = None ZLIB_ROOT = None FUZZING_BUILD = "LIB_FUZZING_ENGINE" in os.environ @@ -459,6 +461,8 @@ class pil_build_ext(build_ext): "FREETYPE_ROOT": "freetype2", "HARFBUZZ_ROOT": "harfbuzz", "FRIBIDI_ROOT": "fribidi", + "RAQM_ROOT": "raqm", + "WEBP_ROOT": "libwebp", "LCMS_ROOT": "lcms2", "IMAGEQUANT_ROOT": "libimagequant", }.items(): From f62796dadc35ed21ab5b06be1e655f283046c7cb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Jun 2024 19:35:46 +1000 Subject: [PATCH 70/91] Rearranged code --- src/PIL/ImageFont.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 235a76325..cc00cdfb6 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -848,13 +848,15 @@ def truetype( # 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") - dirs.append(os.path.join(data_home, "fonts")) + 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" - dirs += [os.path.join(ddir, "fonts") for ddir in data_dirs.split(":")] + 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", From e4887610e9ac48699d386b399dbcbf104a470e0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 15 Jun 2024 22:40:46 +0000 Subject: [PATCH 71/91] Update dependency cibuildwheel to v2.19.1 --- .ci/requirements-cibw.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index bf1d1315b..0d0f81fbf 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.19.0 +cibuildwheel==2.19.1 From 8d14a452df0a6ab1264294b0b28b35032db4f791 Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Sat, 15 Jun 2024 19:50:10 -0400 Subject: [PATCH 72/91] Update test environment variable for ImageFont Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_imagefont.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 3cba726d6..4e6ec86d9 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -564,7 +564,7 @@ def test_find_font( # catching syntax like errors monkeypatch.setattr(sys, "platform", platform) if platform == "linux": - monkeypatch.setenv("XDG_DATA_HOME", "/home/__pillow__/.local/share") + monkeypatch.setenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: From b2c4539cd99da978d523dcaf2d8577b6ee5a7934 Mon Sep 17 00:00:00 2001 From: mamg22 <45301823+mamg22@users.noreply.github.com> Date: Sat, 15 Jun 2024 19:51:59 -0400 Subject: [PATCH 73/91] Remove releate note for truetype() changes The proposed changes don't match match with previous release notes' meaning of "API Changes". --- docs/releasenotes/10.4.0.rst | 8 -------- 1 file changed, 8 deletions(-) diff --git a/docs/releasenotes/10.4.0.rst b/docs/releasenotes/10.4.0.rst index 1678b460c..44727efd4 100644 --- a/docs/releasenotes/10.4.0.rst +++ b/docs/releasenotes/10.4.0.rst @@ -42,14 +42,6 @@ The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecat API Changes =========== -ImageFont.truetype -^^^^^^^^^^^^^^^^^^ - -:py:func:`~PIL.ImageFont.truetype` now searches for fonts in the directory indicated -by the ``XDG_DATA_HOME`` environment variable in addition to those specified in -``XDG_DATA_DIRS``. Additionally, the default for ``XDG_DATA_DIRS`` has been updated -to match the freedesktop spec. - TODO ^^^^ From 6b5b2f6e58ed77aad8b2950319a5c0178b00285a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Jun 2024 22:44:17 +1000 Subject: [PATCH 74/91] Added type hints to Image --- src/PIL/FitsImagePlugin.py | 6 +- src/PIL/GifImagePlugin.py | 2 + src/PIL/Image.py | 173 +++++++++++++++++++++-------------- src/PIL/Jpeg2KImagePlugin.py | 4 +- src/PIL/PalmImagePlugin.py | 9 +- src/PIL/_imaging.pyi | 6 ++ 6 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index a169b6083..4846054b1 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -115,7 +115,11 @@ class FitsImageFile(ImageFile.ImageFile): elif number_of_bits in (-32, -64): self._mode = "F" - args = (self.mode, 0, -1) if decoder_name == "raw" else (number_of_bits,) + args: tuple[str | int, ...] + if decoder_name == "raw": + args = (self.mode, 0, -1) + else: + args = (number_of_bits,) return decoder_name, offset, args diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index a305e8de6..541d97f8c 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -458,6 +458,8 @@ class GifImageFile(ImageFile.ImageFile): 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 diff --git a/src/PIL/Image.py b/src/PIL/Image.py index bdd869ccc..472581127 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -410,7 +410,9 @@ def init() -> bool: # Codec factories (used by tobytes/frombytes and ImageFile.load) -def _getdecoder(mode, decoder_name, args, extra=()): +def _getdecoder( + mode: str, decoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingDecoder | ImageFile.PyDecoder: # tweak arguments if args is None: args = () @@ -433,7 +435,9 @@ def _getdecoder(mode, decoder_name, args, extra=()): return decoder(mode, *args + extra) -def _getencoder(mode, encoder_name, args, extra=()): +def _getencoder( + mode: str, encoder_name: str, args: Any, extra: tuple[Any, ...] = () +) -> core.ImagingEncoder | ImageFile.PyEncoder: # tweak arguments if args is None: args = () @@ -550,10 +554,10 @@ class Image: return self._size @property - def mode(self): + def mode(self) -> str: return self._mode - def _new(self, im) -> Image: + def _new(self, im: core.ImagingCore) -> Image: new = Image() new.im = im new._mode = im.mode @@ -687,7 +691,7 @@ class Image: ) ) - def _repr_image(self, image_format, **kwargs): + def _repr_image(self, image_format: str, **kwargs: Any) -> bytes | None: """Helper function for iPython display hook. :param image_format: Image format. @@ -700,14 +704,14 @@ class Image: return None return b.getvalue() - def _repr_png_(self): + def _repr_png_(self) -> bytes | None: """iPython display hook support for PNG format. :returns: PNG version of the image as bytes """ return self._repr_image("PNG", compress_level=1) - def _repr_jpeg_(self): + def _repr_jpeg_(self) -> bytes | None: """iPython display hook support for JPEG format. :returns: JPEG version of the image as bytes @@ -754,7 +758,7 @@ class Image: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name: str = "raw", *args) -> bytes: + def tobytes(self, encoder_name: str = "raw", *args: Any) -> bytes: """ Return image as a bytes object. @@ -776,12 +780,13 @@ class Image: :returns: A :py:class:`bytes` object. """ - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + encoder_args: Any = args + if len(encoder_args) == 1 and isinstance(encoder_args[0], tuple): + # may pass tuple instead of argument list + encoder_args = encoder_args[0] - if encoder_name == "raw" and args == (): - args = self.mode + if encoder_name == "raw" and encoder_args == (): + encoder_args = self.mode self.load() @@ -789,7 +794,7 @@ class Image: return b"" # unpack data - e = _getencoder(self.mode, encoder_name, args) + e = _getencoder(self.mode, encoder_name, encoder_args) e.setimage(self.im) bufsize = max(65536, self.size[0] * 4) # see RawEncode.c @@ -832,7 +837,9 @@ class Image: ] ) - def frombytes(self, data: bytes, decoder_name: str = "raw", *args) -> None: + def frombytes( + self, data: bytes | bytearray, decoder_name: str = "raw", *args: Any + ) -> None: """ Loads this image with pixel data from a bytes object. @@ -843,16 +850,17 @@ class Image: if self.width == 0 or self.height == 0: return - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] # default format - if decoder_name == "raw" and args == (): - args = self.mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = self.mode # unpack data - d = _getdecoder(self.mode, decoder_name, args) + d = _getdecoder(self.mode, decoder_name, decoder_args) d.setimage(self.im) s = d.decode(data) @@ -996,9 +1004,11 @@ class Image: if has_transparency and self.im.bands == 3: transparency = new_im.info["transparency"] - def convert_transparency(m, v): - v = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 - return max(0, min(255, int(v))) + def convert_transparency( + m: tuple[float, ...], v: tuple[int, int, int] + ) -> int: + value = m[0] * v[0] + m[1] * v[1] + m[2] * v[2] + m[3] * 0.5 + return max(0, min(255, int(value))) if mode == "L": transparency = convert_transparency(matrix, transparency) @@ -1250,7 +1260,7 @@ class Image: __copy__ = copy - def crop(self, box: tuple[int, int, int, int] | None = None) -> Image: + def crop(self, box: tuple[float, float, float, float] | None = None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1276,7 +1286,9 @@ class Image: self.load() return self._new(self._crop(self.im, box)) - def _crop(self, im, box): + def _crop( + self, im: core.ImagingCore, box: tuple[float, float, float, float] + ) -> core.ImagingCore: """ Returns a rectangular region from the core image object im. @@ -1448,7 +1460,7 @@ class Image: return self.im.getextrema() def _getxmp(self, xmp_tags): - def get_name(tag): + def get_name(tag: str) -> str: return re.sub("^{[^}]+}", "", tag) def get_value(element): @@ -1549,7 +1561,11 @@ class Image: fp = io.BytesIO(data) with open(fp) as im: - if thumbnail_offset is None: + from . import TiffImagePlugin + + if thumbnail_offset is None and isinstance( + im, TiffImagePlugin.TiffImageFile + ): im._frame_pos = [ifd_offset] im._seek(0) im.load() @@ -1803,7 +1819,9 @@ class Image: else: self.im.paste(im, box) - def alpha_composite(self, im, dest=(0, 0), source=(0, 0)): + def alpha_composite( + self, im: Image, dest: Sequence[int] = (0, 0), source: Sequence[int] = (0, 0) + ) -> None: """'In-place' analog of Image.alpha_composite. Composites an image onto this image. @@ -1818,32 +1836,35 @@ class Image: """ if not isinstance(source, (list, tuple)): - msg = "Source must be a tuple" + msg = "Source must be a list or tuple" raise ValueError(msg) if not isinstance(dest, (list, tuple)): - msg = "Destination must be a tuple" + msg = "Destination must be a list or tuple" raise ValueError(msg) - if len(source) not in (2, 4): - msg = "Source must be a 2 or 4-tuple" + + if len(source) == 4: + overlay_crop_box = tuple(source) + elif len(source) == 2: + overlay_crop_box = tuple(source) + im.size + else: + msg = "Source must be a sequence of length 2 or 4" raise ValueError(msg) + if not len(dest) == 2: - msg = "Destination must be a 2-tuple" + msg = "Destination must be a sequence of length 2" raise ValueError(msg) if min(source) < 0: msg = "Source must be non-negative" raise ValueError(msg) - if len(source) == 2: - source = source + im.size - - # over image, crop if it's not the whole thing. - if source == (0, 0) + im.size: + # over image, crop if it's not the whole image. + if overlay_crop_box == (0, 0) + im.size: overlay = im else: - overlay = im.crop(source) + overlay = im.crop(overlay_crop_box) # target for the paste - box = dest + (dest[0] + overlay.width, dest[1] + overlay.height) + box = tuple(dest) + (dest[0] + overlay.width, dest[1] + overlay.height) # destination image. don't copy if we're using the whole image. if box == (0, 0) + self.size: @@ -1854,7 +1875,11 @@ class Image: result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode: str | None = None) -> Image: + def point( + self, + lut: Sequence[float] | Callable[[int], float] | ImagePointHandler, + mode: str | None = None, + ) -> Image: """ Maps this image through a lookup table or function. @@ -1891,7 +1916,9 @@ class Image: scale, offset = _getscaleoffset(lut) return self._new(self.im.point_transform(scale, offset)) # for other modes, convert the function to a table - lut = [lut(i) for i in range(256)] * self.im.bands + flatLut = [lut(i) for i in range(256)] * self.im.bands + else: + flatLut = lut if self.mode == "F": # FIXME: _imaging returns a confusing error message for this case @@ -1899,8 +1926,8 @@ class Image: raise ValueError(msg) if mode != "F": - lut = [round(i) for i in lut] - return self._new(self.im.point(lut, mode)) + flatLut = [round(i) for i in flatLut] + return self._new(self.im.point(flatLut, mode)) def putalpha(self, alpha): """ @@ -2973,29 +3000,29 @@ def _wedge() -> Image: return Image()._new(core.wedge("L")) -def _check_size(size): +def _check_size(size: Any) -> None: """ Common check to enforce type and sanity check on size tuples :param size: Should be a 2 tuple of (width, height) - :returns: True, or raises a ValueError + :returns: None, or raises a ValueError """ if not isinstance(size, (list, tuple)): - msg = "Size must be a tuple" + msg = "Size must be a list or tuple" raise ValueError(msg) if len(size) != 2: - msg = "Size must be a tuple of length 2" + msg = "Size must be a sequence of length 2" raise ValueError(msg) if size[0] < 0 or size[1] < 0: msg = "Width and height must be >= 0" raise ValueError(msg) - return True - def new( - mode: str, size: tuple[int, int], color: float | tuple[float, ...] | str | None = 0 + mode: str, + size: tuple[int, int] | list[int], + color: float | tuple[float, ...] | str | None = 0, ) -> Image: """ Creates a new image with the given mode and size. @@ -3044,7 +3071,13 @@ def new( return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name: str = "raw", *args) -> Image: +def frombytes( + mode: str, + size: tuple[int, int], + data: bytes | bytearray, + decoder_name: str = "raw", + *args: Any, +) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. @@ -3072,18 +3105,21 @@ def frombytes(mode, size, data, decoder_name: str = "raw", *args) -> Image: im = new(mode, size) if im.width != 0 and im.height != 0: - # may pass tuple instead of argument list - if len(args) == 1 and isinstance(args[0], tuple): - args = args[0] + decoder_args: Any = args + if len(decoder_args) == 1 and isinstance(decoder_args[0], tuple): + # may pass tuple instead of argument list + decoder_args = decoder_args[0] - if decoder_name == "raw" and args == (): - args = mode + if decoder_name == "raw" and decoder_args == (): + decoder_args = mode - im.frombytes(data, decoder_name, args) + im.frombytes(data, decoder_name, decoder_args) return im -def frombuffer(mode: str, size, data, decoder_name: str = "raw", *args) -> Image: +def frombuffer( + mode: str, size: tuple[int, int], data, decoder_name: str = "raw", *args: Any +) -> Image: """ Creates an image memory referencing pixel data in a byte buffer. @@ -3540,7 +3576,7 @@ def merge(mode: str, bands: Sequence[Image]) -> Image: def register_open( - id, + id: str, factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], accept: Callable[[bytes], bool | str] | None = None, ) -> None: @@ -3674,7 +3710,7 @@ def _show(image: Image, **options: Any) -> None: def effect_mandelbrot( - size: tuple[int, int], extent: tuple[int, int, int, int], quality: int + size: tuple[int, int], extent: tuple[float, float, float, float], quality: int ) -> Image: """ Generate a Mandelbrot set covering the given extent. @@ -3721,19 +3757,18 @@ def radial_gradient(mode: str) -> Image: # Resources -def _apply_env_variables(env=None) -> None: - if env is None: - env = os.environ +def _apply_env_variables(env: dict[str, str] | None = None) -> None: + env_dict = env if env is not None else os.environ for var_name, setter in [ ("PILLOW_ALIGNMENT", core.set_alignment), ("PILLOW_BLOCK_SIZE", core.set_block_size), ("PILLOW_BLOCKS_MAX", core.set_blocks_max), ]: - if var_name not in env: + if var_name not in env_dict: continue - var = env[var_name].lower() + var = env_dict[var_name].lower() units = 1 for postfix, mul in [("k", 1024), ("m", 1024 * 1024)]: @@ -3742,13 +3777,13 @@ def _apply_env_variables(env=None) -> None: var = var[: -len(postfix)] try: - var = int(var) * units + var_int = int(var) * units except ValueError: warnings.warn(f"{var_name} is not int") continue try: - setter(var) + setter(var_int) except ValueError as e: warnings.warn(f"{var_name}: {e}") diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 60f3bff0a..39eb1c203 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -122,7 +122,7 @@ def _parse_codestream(fp): elif csiz == 4: mode = "RGBA" else: - mode = None + mode = "" return size, mode @@ -237,7 +237,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): msg = "not a JPEG 2000 file" raise SyntaxError(msg) - if self.size is None or self.mode is None: + if self.size is None or not self.mode: msg = "unable to determine size/mode" raise SyntaxError(msg) diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index fc83918b5..1735070f8 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -129,15 +129,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # and invert it because # 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) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 1fe954417..3467eeb45 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -12,5 +12,11 @@ class ImagingDraw: class PixelAccess: def __getattr__(self, name: str) -> Any: ... +class ImagingDecoder: + def __getattr__(self, name: str) -> Any: ... + +class ImagingEncoder: + def __getattr__(self, name: str) -> Any: ... + def font(image, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... From 291ee352047c845e2e6e5062c2c219923689da8d Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Jun 2024 23:03:03 +1000 Subject: [PATCH 75/91] Added type hints --- Tests/test_file_jpeg.py | 2 +- Tests/test_file_jpeg2k.py | 2 +- Tests/test_image.py | 2 +- Tests/test_image_putdata.py | 8 ++++---- Tests/test_numpy.py | 25 +++++++++++++++++-------- Tests/test_shell_injection.py | 7 ++++--- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 8e4d694c1..1459a87eb 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -701,7 +701,7 @@ class TestFileJpeg: def test_save_cjpeg(self, tmp_path: Path) -> None: with Image.open(TEST_FILE) as img: tempfile = str(tmp_path / "temp.jpg") - JpegImagePlugin._save_cjpeg(img, 0, tempfile) + JpegImagePlugin._save_cjpeg(img, BytesIO(), tempfile) # Default save quality is 75%, so a tiny bit of difference is alright assert_image_similar_tofile(img, tempfile, 17) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 5a208739f..ed7ea4fcf 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -460,7 +460,7 @@ def test_plt_marker() -> None: out.seek(length - 2, os.SEEK_CUR) -def test_9bit(): +def test_9bit() -> None: with Image.open("Tests/images/9bit.j2k") as im: assert im.mode == "I;16" assert im.size == (128, 128) diff --git a/Tests/test_image.py b/Tests/test_image.py index d6a739c79..0d7d03480 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -152,7 +152,7 @@ class TestImage: def test_stringio(self) -> None: with pytest.raises(ValueError): - with Image.open(io.StringIO()): + with Image.open(io.StringIO()): # type: ignore[arg-type] pass def test_pathlib(self, tmp_path: Path) -> None: diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index dad26ef14..5e57e4c4c 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -113,13 +113,13 @@ def test_array_F() -> None: def test_not_flattened() -> None: im = Image.new("L", (1, 1)) with pytest.raises(TypeError): - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): - im.putdata([[0]], 2) + im.putdata([[0]], 2) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("I", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] with pytest.raises(TypeError): im = Image.new("F", (1, 1)) - im.putdata([[0]]) + im.putdata([[0]]) # type: ignore[list-item] diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 9f4e6534e..36cdb3682 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from typing import TYPE_CHECKING, Any import pytest @@ -8,13 +9,19 @@ from PIL import Image from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature -numpy = pytest.importorskip("numpy", reason="NumPy not installed") +if TYPE_CHECKING: + import numpy + import numpy.typing +else: + numpy = pytest.importorskip("numpy", reason="NumPy not installed") TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: + def to_image( + dtype: numpy.typing.DTypeLike, bands: int = 1, boolean: int = 0 + ) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -99,14 +106,16 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray(img: Image.Image, np) -> None: - assert len(np.shape) >= 2 - np_size = np.shape[1], np.shape[0] +def _test_img_equals_nparray( + img: Image.Image, np_img: numpy.typing.NDArray[Any] +) -> None: + assert len(np_img.shape) >= 2 + np_size = np_img.shape[1], np_img.shape[0] assert img.size == np_size px = img.load() for x in range(0, img.size[0], int(img.size[0] / 10)): for y in range(0, img.size[1], int(img.size[1] / 10)): - assert_deep_equal(px[x, y], np[y, x]) + assert_deep_equal(px[x, y], np_img[y, x]) def test_16bit() -> None: @@ -157,7 +166,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode: str, dtype) -> None: +def test_to_array(mode: str, dtype: numpy.typing.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -207,7 +216,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype) -> None: +def test_roundtrip_eye(dtype: numpy.typing.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index 2a072fd44..dd4fc46c3 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -1,8 +1,9 @@ from __future__ import annotations import shutil +from io import BytesIO from pathlib import Path -from typing import Callable +from typing import IO, Callable import pytest @@ -22,11 +23,11 @@ class TestShellInjection: self, tmp_path: Path, src_img: Image.Image, - save_func: Callable[[Image.Image, int, str], None], + save_func: Callable[[Image.Image, IO[bytes], str | bytes], None], ) -> None: for filename in test_filenames: dest_file = str(tmp_path / filename) - save_func(src_img, 0, dest_file) + save_func(src_img, BytesIO(), dest_file) # If file can't be opened, shell injection probably occurred with Image.open(dest_file) as im: im.load() From 9f79e5d7680a0eba9b625ad24595fc2ece6e523c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 19 Jun 2024 08:43:23 +1000 Subject: [PATCH 76/91] Added type hints to ImageDraw shape methods --- Tests/test_imagedraw.py | 20 +++- src/PIL/ImageDraw.py | 248 +++++++++++++++++++++++++++------------- src/PIL/_imaging.pyi | 5 + 3 files changed, 187 insertions(+), 86 deletions(-) diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index d9593c60f..8b55df992 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -448,6 +448,7 @@ def test_shape1() -> None: x3, y3 = 95, 5 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -469,6 +470,7 @@ def test_shape2() -> None: x3, y3 = 5, 95 # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -487,6 +489,7 @@ def test_transform() -> None: draw = ImageDraw.Draw(im) # Act + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.line(0, 0) s.transform((0, 0, 0, 0, 0, 0)) @@ -913,7 +916,12 @@ def test_rounded_rectangle_translucent( def test_floodfill(bbox: Coords) -> None: red = ImageColor.getrgb("red") - for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: + mode_values: list[tuple[str, int | tuple[int, ...]]] = [ + ("L", 1), + ("RGBA", (255, 0, 0, 0)), + ("RGB", red), + ] + for mode, value in mode_values: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -1429,6 +1437,7 @@ def test_same_color_outline(bbox: Coords) -> None: x2, y2 = 95, 50 x3, y3 = 95, 5 + assert ImageDraw.Outline is not None s = ImageDraw.Outline() s.move(x0, y0) s.curve(x1, y1, x2, y2, x3, y3) @@ -1467,7 +1476,7 @@ def test_same_color_outline(bbox: Coords) -> None: (4, "square", {}), (8, "regular_octagon", {}), (4, "square_rotate_45", {"rotation": 45}), - (3, "triangle_width", {"width": 5, "outline": "yellow"}), + (3, "triangle_width", {"outline": "yellow", "width": 5}), ], ) def test_draw_regular_polygon( @@ -1477,7 +1486,10 @@ def test_draw_regular_polygon( filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) bounding_circle = ((W // 2, H // 2), 25) - draw.regular_polygon(bounding_circle, n_sides, fill="red", **args) + rotation = int(args.get("rotation", 0)) + outline = args.get("outline") + width = int(args.get("width", 1)) + draw.regular_polygon(bounding_circle, n_sides, rotation, "red", outline, width) assert_image_equal_tofile(im, filename) @@ -1630,6 +1642,6 @@ def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: draw.rounded_rectangle(xy) -def test_getdraw(): +def test_getdraw() -> None: with pytest.warns(DeprecationWarning): ImageDraw.getdraw(None, []) diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 41a3eb0cb..91f898838 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -35,15 +35,24 @@ import math import numbers import struct from types import ModuleType -from typing import TYPE_CHECKING, AnyStr, Sequence, cast +from typing import TYPE_CHECKING, AnyStr, Callable, List, Sequence, Tuple, Union, cast from . import Image, ImageColor from ._deprecate import deprecate from ._typing import Coords +# experimental access to the outline API +Outline: Callable[[], Image.core._Outline] | None +try: + Outline = Image.core.outline +except AttributeError: + Outline = None + if TYPE_CHECKING: from . import ImageDraw2, ImageFont +_Ink = Union[float, Tuple[int, ...], str] + """ A simple 2D drawing interface for PIL images.

@@ -134,34 +143,47 @@ class ImageDraw: else: return self.getfont() - def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: + def _getink( + self, ink: _Ink | None, fill: _Ink | None = None + ) -> tuple[int | None, int | None]: + result_ink = None + result_fill = None if ink is None and fill is None: if self.fill: - fill = self.ink + result_fill = self.ink else: - ink = self.ink + result_ink = self.ink else: if ink is not None: if isinstance(ink, str): ink = ImageColor.getcolor(ink, self.mode) if self.palette and not isinstance(ink, numbers.Number): ink = self.palette.getcolor(ink, self._image) - ink = self.draw.draw_ink(ink) + result_ink = self.draw.draw_ink(ink) if fill is not None: if isinstance(fill, str): fill = ImageColor.getcolor(fill, self.mode) if self.palette and not isinstance(fill, numbers.Number): fill = self.palette.getcolor(fill, self._image) - fill = self.draw.draw_ink(fill) - return ink, fill + result_fill = self.draw.draw_ink(fill) + return result_ink, result_fill - def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: + def arc( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: + def bitmap( + self, xy: Sequence[int], bitmap: Image.Image, fill: _Ink | None = None + ) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -170,30 +192,55 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: + def chord( + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a chord.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_chord(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_chord(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def ellipse( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw an ellipse.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_ellipse(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_ellipse(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) def circle( - self, xy: Sequence[float], radius: float, fill=None, outline=None, width=1 + self, + xy: Sequence[float], + radius: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a circle given center coordinates and a radius.""" ellipse_xy = (xy[0] - radius, xy[1] - radius, xy[0] + radius, xy[1] + radius) self.ellipse(ellipse_xy, fill, outline, width) - def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: + def line( + self, + xy: Coords, + fill: _Ink | None = None, + width: int = 0, + joint: str | None = None, + ) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: @@ -223,7 +270,7 @@ class ImageDraw: def coord_at_angle( coord: Sequence[float], angle: float - ) -> tuple[float, float]: + ) -> tuple[float, ...]: x, y = coord angle -= 90 distance = width / 2 - 1 @@ -264,37 +311,54 @@ class ImageDraw: ] self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None) -> None: + def shape( + self, + shape: Image.core._Outline, + fill: _Ink | None = None, + outline: _Ink | None = None, + ) -> None: """(Experimental) Draw a shape.""" shape.close() - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_outline(shape, fill, 1) - if ink is not None and ink != fill: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_outline(shape, fill_ink, 1) + if ink is not None and ink != fill_ink: self.draw.draw_outline(shape, ink, 0) def pieslice( - self, xy: Coords, start, end, fill=None, outline=None, width=1 + self, + xy: Coords, + start: float, + end: float, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a pieslice.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_pieslice(xy, start, end, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_pieslice(xy, start, end, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy: Coords, fill=None) -> None: + def point(self, xy: Coords, fill: _Ink | None = None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def polygon( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a polygon.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_polygon(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_polygon(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: if width == 1: self.draw.draw_polygon(xy, ink, 0, width) elif self.im is not None: @@ -320,22 +384,41 @@ class ImageDraw: self.im.paste(im.im, (0, 0) + im.size, mask.im) def regular_polygon( - self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 + self, + bounding_circle: Sequence[Sequence[float] | float], + n_sides: int, + rotation: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: + def rectangle( + self, + xy: Coords, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + ) -> None: """Draw a rectangle.""" - ink, fill = self._getink(outline, fill) - if fill is not None: - self.draw.draw_rectangle(xy, fill, 1) - if ink is not None and ink != fill and width != 0: + ink, fill_ink = self._getink(outline, fill) + if fill_ink is not None: + self.draw.draw_rectangle(xy, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( - self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None + self, + xy: Coords, + radius: float = 0, + fill: _Ink | None = None, + outline: _Ink | None = None, + width: int = 1, + *, + corners: tuple[bool, bool, bool, bool] | None = None, ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): @@ -377,10 +460,10 @@ class ImageDraw: # that is a rectangle return self.rectangle(xy, fill, outline, width) - r = d // 2 - ink, fill = self._getink(outline, fill) + r = int(d // 2) + ink, fill_ink = self._getink(outline, fill) - def draw_corners(pieslice) -> None: + def draw_corners(pieslice: bool) -> None: parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves @@ -410,32 +493,32 @@ class ImageDraw: ) for part in parts: if pieslice: - self.draw.draw_pieslice(*(part + (fill, 1))) + self.draw.draw_pieslice(*(part + (fill_ink, 1))) else: self.draw.draw_arc(*(part + (ink, width))) - if fill is not None: + if fill_ink is not None: draw_corners(True) if full_x: - self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill, 1) + self.draw.draw_rectangle((x0, y0 + r + 1, x1, y1 - r - 1), fill_ink, 1) else: - self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill, 1) + self.draw.draw_rectangle((x0 + r + 1, y0, x1 - r - 1, y1), fill_ink, 1) if not full_x and not full_y: left = [x0, y0, x0 + r, y1] if corners[0]: left[1] += r + 1 if corners[3]: left[3] -= r + 1 - self.draw.draw_rectangle(left, fill, 1) + self.draw.draw_rectangle(left, fill_ink, 1) right = [x1 - r, y0, x1, y1] if corners[1]: right[1] += r + 1 if corners[2]: right[3] -= r + 1 - self.draw.draw_rectangle(right, fill, 1) - if ink is not None and ink != fill and width != 0: + self.draw.draw_rectangle(right, fill_ink, 1) + if ink is not None and ink != fill_ink and width != 0: draw_corners(False) if not full_x: @@ -530,10 +613,11 @@ class ImageDraw: embedded_color, ) - def getink(fill): - ink, fill = self._getink(fill) + def getink(fill: _Ink | None) -> int: + ink, fill_ink = self._getink(fill) if ink is None: - return fill + assert fill_ink is not None + return fill_ink return ink def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: @@ -897,13 +981,6 @@ def Draw(im: Image.Image, mode: str | None = None) -> ImageDraw: return ImageDraw(im, mode) -# experimental access to the outline API -try: - Outline = Image.core.outline -except AttributeError: - Outline = None - - def getdraw( im: Image.Image | None = None, hints: list[str] | None = None ) -> tuple[ImageDraw2.Draw | None, ModuleType]: @@ -983,12 +1060,12 @@ def floodfill( def _compute_regular_polygon_vertices( - bounding_circle, n_sides, rotation + bounding_circle: Sequence[Sequence[float] | float], n_sides: int, rotation: float ) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. - :param bounding_circle: The bounding circle is a tuple defined + :param bounding_circle: The bounding circle is a sequence defined by a point and radius. The polygon is inscribed in this circle. (e.g. ``bounding_circle=(x, y, r)`` or ``((x, y), r)``) :param n_sides: Number of sides @@ -1026,7 +1103,7 @@ def _compute_regular_polygon_vertices( # 1. Error Handling # 1.1 Check `n_sides` has an appropriate value if not isinstance(n_sides, int): - msg = "n_sides should be an int" + msg = "n_sides should be an int" # type: ignore[unreachable] raise TypeError(msg) if n_sides < 3: msg = "n_sides should be an int > 2" @@ -1038,9 +1115,24 @@ def _compute_regular_polygon_vertices( raise TypeError(msg) if len(bounding_circle) == 3: - *centroid, polygon_radius = bounding_circle - elif len(bounding_circle) == 2: - centroid, polygon_radius = bounding_circle + if not all(isinstance(i, (int, float)) for i in bounding_circle): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + *centroid, polygon_radius = cast(List[float], list(bounding_circle)) + elif len(bounding_circle) == 2 and isinstance(bounding_circle[0], (list, tuple)): + if not all( + isinstance(i, (int, float)) for i in bounding_circle[0] + ) or not isinstance(bounding_circle[1], (int, float)): + msg = "bounding_circle should only contain numeric data" + raise ValueError(msg) + + if len(bounding_circle[0]) != 2: + msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" + raise ValueError(msg) + + centroid = cast(List[float], list(bounding_circle[0])) + polygon_radius = cast(float, bounding_circle[1]) else: msg = ( "bounding_circle should contain 2D coordinates " @@ -1048,25 +1140,17 @@ def _compute_regular_polygon_vertices( ) raise ValueError(msg) - if not all(isinstance(i, (int, float)) for i in (*centroid, polygon_radius)): - msg = "bounding_circle should only contain numeric data" - raise ValueError(msg) - - if not len(centroid) == 2: - msg = "bounding_circle centre should contain 2D coordinates (e.g. (x, y))" - raise ValueError(msg) - if polygon_radius <= 0: msg = "bounding_circle radius should be > 0" raise ValueError(msg) # 1.3 Check `rotation` has an appropriate value if not isinstance(rotation, (int, float)): - msg = "rotation should be an int or float" + msg = "rotation should be an int or float" # type: ignore[unreachable] raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: + def _apply_rotation(point: list[float], degrees: float) -> tuple[float, float]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -1082,7 +1166,7 @@ def _compute_regular_polygon_vertices( ), ) - def _compute_polygon_vertex(angle: float) -> tuple[int, int]: + def _compute_polygon_vertex(angle: float) -> tuple[float, float]: start_point = [polygon_radius, 0] return _apply_rotation(start_point, angle) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index dda06b5a1..2bfe421e6 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -18,5 +18,10 @@ class ImagingDecoder: class ImagingEncoder: def __getattr__(self, name: str) -> Any: ... +class _Outline: + def close(self) -> None: ... + def __getattr__(self, name: str) -> Any: ... + def font(image: ImagingCore, glyphdata: bytes) -> ImagingFont: ... +def outline() -> _Outline: ... def __getattr__(name: str) -> Any: ... From b64847e07fd32f8865f45600f663655116d46840 Mon Sep 17 00:00:00 2001 From: Nulano Date: Wed, 19 Jun 2024 21:48:48 +0200 Subject: [PATCH 77/91] Do not use a protocol for PixelAccess object --- docs/reference/PixelAccess.rst | 22 +++++++++++++++++++--- src/PIL/Image.py | 29 ++--------------------------- src/PIL/_imaging.pyi | 5 ++++- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/reference/PixelAccess.rst b/docs/reference/PixelAccess.rst index 026f488d8..1ac3d034b 100644 --- a/docs/reference/PixelAccess.rst +++ b/docs/reference/PixelAccess.rst @@ -44,7 +44,23 @@ Access using negative indexes is also possible. :: ----------------------------- .. class:: PixelAccess - :canonical: PIL.Image.PixelAccess + :canonical: PIL.Image.core.PixelAccess - .. automethod:: PIL.Image.PixelAccess.__getitem__ - .. automethod:: PIL.Image.PixelAccess.__setitem__ + .. method:: __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...] + + Returns the pixel at x,y. The pixel is returned as a single + value for single band images or a tuple for multi-band images. + + :param xy: The pixel coordinate, given as (x, y). + :returns: a pixel value for single band images, a tuple of + pixel values for multiband images. + + .. method:: __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None + + 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). + :param color: The pixel value according to its mode, + e.g. tuple (r, g, b) for RGB mode. diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 5619915ea..26849707e 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -227,7 +227,7 @@ if hasattr(core, "DEFAULT_STRATEGY"): # Registries if TYPE_CHECKING: - from . import ImageFile + from . import ImageFile, PyAccess ID: list[str] = [] OPEN: dict[ str, @@ -512,31 +512,6 @@ def _getscaleoffset(expr): # Implementation wrapper -class PixelAccess(Protocol): - def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: - """ - Returns the pixel at x,y. The pixel is returned as a single - value for single band images or a tuple for multi-band images. - - :param xy: The pixel coordinate, given as (x, y). - :returns: a pixel value for single band images, a tuple of - pixel values for multiband images. - """ - raise NotImplementedError() - - def __setitem__(self, xy: tuple[int, int], color: float | tuple[int, ...]) -> None: - """ - 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). - :param color: The pixel value according to its mode, - e.g. tuple (r, g, b) for RGB mode. - """ - raise NotImplementedError() - - class SupportsGetData(Protocol): def getdata( self, @@ -897,7 +872,7 @@ class Image: msg = "cannot decode image data" raise ValueError(msg) - def load(self) -> PixelAccess | None: + def load(self) -> core.PixelAccess | PyAccess.PyAccess | None: """ Allocates storage for the image and loads the pixel data. In normal cases, you don't need to call this method, since the diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi index 1fe954417..1a0184069 100644 --- a/src/PIL/_imaging.pyi +++ b/src/PIL/_imaging.pyi @@ -10,7 +10,10 @@ class ImagingDraw: def __getattr__(self, name: str) -> Any: ... class PixelAccess: - def __getattr__(self, name: str) -> Any: ... + def __getitem__(self, xy: tuple[int, int]) -> float | tuple[int, ...]: ... + def __setitem__( + self, xy: tuple[int, int], color: float | tuple[int, ...] + ) -> None: ... def font(image, glyphdata: bytes) -> ImagingFont: ... def __getattr__(name: str) -> Any: ... From 324e548e525f2e7a3e31bb9dde10086fc202e8b5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Jun 2024 20:41:22 +1000 Subject: [PATCH 78/91] Added type hints to ImageFilter --- Tests/test_color_lut.py | 4 +- Tests/test_image_filter.py | 2 +- Tests/test_numpy.py | 18 +++----- docs/reference/internal_modules.rst | 4 ++ src/PIL/ImageFilter.py | 72 ++++++++++++++++++++--------- src/PIL/_typing.py | 10 +++- 6 files changed, 71 insertions(+), 39 deletions(-) diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index c8886a779..00c8995b0 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -354,10 +354,10 @@ class TestColorLut3DCoreAPI: class TestColorLut3DFilter: def test_wrong_args(self) -> None: with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT("small", [1]) + ImageFilter.Color3DLUT("small", [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match="should be either an integer"): - ImageFilter.Color3DLUT((11, 11), [1]) + ImageFilter.Color3DLUT((11, 11), [1]) # type: ignore[arg-type] with pytest.raises(ValueError, match=r"in \[2, 65\] range"): ImageFilter.Color3DLUT((11, 11, 1), [1]) diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 1f0644471..412ab44c3 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -137,7 +137,7 @@ def test_builtinfilter_p() -> None: builtin_filter = ImageFilter.BuiltinFilter() with pytest.raises(ValueError): - builtin_filter.filter(hopper("P")) + builtin_filter.filter(hopper("P").im) def test_kernel_not_enough_coefficients() -> None: diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 36cdb3682..32d2cf985 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,17 +1,17 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import pytest -from PIL import Image +from PIL import Image, _typing from .helper import assert_deep_equal, assert_image, hopper, skip_unless_feature if TYPE_CHECKING: import numpy - import numpy.typing + import numpy.typing as npt else: numpy = pytest.importorskip("numpy", reason="NumPy not installed") @@ -19,9 +19,7 @@ TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: - def to_image( - dtype: numpy.typing.DTypeLike, bands: int = 1, boolean: int = 0 - ) -> Image.Image: + def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -106,9 +104,7 @@ def test_1d_array() -> None: assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray( - img: Image.Image, np_img: numpy.typing.NDArray[Any] -) -> None: +def _test_img_equals_nparray(img: Image.Image, np_img: _typing.NumpyArray) -> None: assert len(np_img.shape) >= 2 np_size = np_img.shape[1], np_img.shape[0] assert img.size == np_size @@ -166,7 +162,7 @@ def test_save_tiff_uint16() -> None: ("HSV", numpy.uint8), ), ) -def test_to_array(mode: str, dtype: numpy.typing.DTypeLike) -> None: +def test_to_array(mode: str, dtype: npt.DTypeLike) -> None: img = hopper(mode) # Resize to non-square @@ -216,7 +212,7 @@ def test_putdata() -> None: numpy.float64, ), ) -def test_roundtrip_eye(dtype: numpy.typing.DTypeLike) -> None: +def test_roundtrip_eye(dtype: npt.DTypeLike) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 899e4966f..e4cb17c4d 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -33,6 +33,10 @@ Internal Modules Provides a convenient way to import type hints that are not available on some Python versions. +.. py:class:: NumpyArray + + Typing alias. + .. py:class:: StrOrBytesPath Typing alias. diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 02288e135..e18b4a446 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -19,12 +19,16 @@ from __future__ import annotations import abc import functools from types import ModuleType -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any, Callable, Sequence, cast + +if TYPE_CHECKING: + from . import _imaging + from ._typing import NumpyArray class Filter: @abc.abstractmethod - def filter(self, image): + def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: pass @@ -33,7 +37,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) @@ -91,7 +97,7 @@ class RankFilter(Filter): 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) @@ -158,7 +164,7 @@ class ModeFilter(Filter): 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) @@ -176,9 +182,9 @@ class GaussianBlur(MultibandFilter): 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() @@ -208,9 +214,9 @@ class BoxBlur(MultibandFilter): 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() @@ -241,7 +247,7 @@ class UnsharpMask(MultibandFilter): 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) @@ -387,8 +393,13 @@ class Color3DLUT(MultibandFilter): name = "Color 3D LUT" def __init__( - self, size, table, channels: int = 3, target_mode: str | None = None, **kwargs - ): + 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) @@ -410,15 +421,16 @@ class Color3DLUT(MultibandFilter): 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 @@ -428,7 +440,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 = ( @@ -436,7 +449,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 = ( @@ -449,7 +463,7 @@ class Color3DLUT(MultibandFilter): self.table = table @staticmethod - def _check_size(size: Any) -> list[int]: + def _check_size(size: Any) -> tuple[int, int, int]: try: _, _, _ = size except ValueError as e: @@ -457,7 +471,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." @@ -465,7 +479,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. @@ -482,7 +502,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): @@ -500,7 +520,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. @@ -564,7 +590,7 @@ 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( diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py index 7075e8672..09ece18fa 100644 --- a/src/PIL/_typing.py +++ b/src/PIL/_typing.py @@ -2,7 +2,14 @@ from __future__ import annotations import os import sys -from typing import Protocol, Sequence, TypeVar, Union +from typing import Any, Protocol, Sequence, TypeVar, Union + +try: + import numpy.typing as npt + + NumpyArray = npt.NDArray[Any] +except ImportError: + pass if sys.version_info >= (3, 10): from typing import TypeGuard @@ -10,7 +17,6 @@ else: try: from typing_extensions import TypeGuard except ImportError: - from typing import Any class TypeGuard: # type: ignore[no-redef] def __class_getitem__(cls, item: Any) -> type[bool]: From c155677c4febece9a280208be11f2dd844fe22ad Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 22 Jun 2024 00:39:37 +1000 Subject: [PATCH 79/91] Removed support for Qt 5 --- Tests/test_imageqt.py | 19 +++++++------------ src/PIL/ImageQt.py | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 88ad1f9ee..22cd674ce 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -41,18 +41,13 @@ def test_rgb() -> None: checkrgb(0, 0, 255) -def test_image() -> None: - modes = ["1", "RGB", "RGBA", "L", "P"] - qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage - if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ - modes.append("I;16") - - for mode in modes: - im = hopper(mode) - roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) - if mode not in ("RGB", "RGBA"): - im = im.convert("RGB") - assert_image_similar(roundtripped_im, im, 1) +@pytest.mark.parametrize("mode", ("1", "RGB", "RGBA", "L", "P", "I;16")) +def test_image(mode: str) -> None: + im = hopper(mode) + roundtripped_im = ImageQt.fromqimage(ImageQt.ImageQt(im)) + if mode not in ("RGB", "RGBA"): + im = im.convert("RGB") + assert_image_similar(roundtripped_im, im, 1) def test_closed_file() -> None: diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 293ba4941..7819ae9c1 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -152,7 +152,7 @@ def _toqclass_helper(im): 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+ + elif im.mode == "I;16": im = im.point(lambda i: i * 256) format = qt_format.Format_Grayscale16 From cc83cc8ec866ac0308fc8fc16ff16893540f77e2 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 22 Jun 2024 10:09:11 +1000 Subject: [PATCH 80/91] Updated type hints --- Tests/helper.py | 4 ++-- Tests/test_file_jpeg.py | 4 +++- Tests/test_file_png.py | 16 +++++++--------- Tests/test_file_ppm.py | 12 ++++-------- Tests/test_file_tiff.py | 2 +- Tests/test_font_leaks.py | 2 +- Tests/test_image.py | 8 ++++---- Tests/test_image_draft.py | 4 +++- Tests/test_image_paste.py | 5 +++++ Tests/test_image_point.py | 2 +- Tests/test_image_putdata.py | 8 ++++---- Tests/test_image_resample.py | 2 +- Tests/test_imagecms.py | 11 ++++++----- Tests/test_imagedraw.py | 2 +- Tests/test_imagedraw2.py | 5 +++-- Tests/test_imagefile.py | 2 +- Tests/test_imagefont.py | 6 +++--- Tests/test_imagefontpil.py | 7 ++++++- Tests/test_imagepalette.py | 2 +- Tests/test_psdraw.py | 12 ++++-------- src/PIL/Image.py | 30 +++++++++++++++++++----------- src/PIL/ImageDraw.py | 4 +++- src/PIL/ImageFont.py | 12 ++++++++---- src/PIL/ImageQt.py | 2 +- src/PIL/ImageTransform.py | 2 +- src/PIL/JpegImagePlugin.py | 2 +- src/PIL/TiffTags.py | 6 ++++-- 27 files changed, 98 insertions(+), 76 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index fe337c09f..605a214d4 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -18,7 +18,7 @@ from typing import Any, Callable, Sequence import pytest from packaging.version import parse as parse_version -from PIL import Image, ImageMath, features +from PIL import Image, ImageFile, ImageMath, features logger = logging.getLogger(__name__) @@ -240,7 +240,7 @@ class PillowLeakTestCase: # helpers -def fromstring(data: bytes) -> Image.Image: +def fromstring(data: bytes) -> ImageFile.ImageFile: return Image.open(BytesIO(data)) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 02d0b29d8..dbd917517 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1033,8 +1033,10 @@ class TestFileJpeg: def test_repr_jpeg(self) -> None: im = hopper() + b = im._repr_jpeg_() + assert b is not None - with Image.open(BytesIO(im._repr_jpeg_())) as repr_jpeg: + with Image.open(BytesIO(b)) as repr_jpeg: assert repr_jpeg.format == "JPEG" assert_image_similar(im, repr_jpeg, 17) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 46714a0f0..0f0976802 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -535,8 +535,10 @@ class TestFilePng: def test_repr_png(self) -> None: im = hopper() + b = im._repr_png_() + assert b is not None - with Image.open(BytesIO(im._repr_png_())) as repr_png: + with Image.open(BytesIO(b)) as repr_png: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) @@ -768,14 +770,10 @@ class TestFilePng: def test_save_stdout(self, buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -785,7 +783,7 @@ class TestFilePng: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 1bfd0434e..0a61830a4 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -368,14 +368,10 @@ def test_mimetypes(tmp_path: Path) -> None: def test_save_stdout(buffer: bool) -> None: old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -385,7 +381,7 @@ def test_save_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_FILE) diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 17dab25e9..08ff334ac 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -120,7 +120,7 @@ class TestFileTiff: def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() with pytest.raises(Exception) as e: - ifd.legacy_api = None + ifd.legacy_api = False assert str(e.value) == "Not allowing setting of legacy api" def test_xyres_tiff(self) -> None: diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index 3fb92a62e..ab8a7f9ec 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -34,7 +34,7 @@ class TestDefaultFontLeak(TestTTypeFontLeak): def test_leak(self) -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) try: default_font = ImageFont.load_default() finally: diff --git a/Tests/test_image.py b/Tests/test_image.py index 35a7a49a8..a8f61d677 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -393,13 +393,13 @@ class TestImage: # errors with pytest.raises(ValueError): - source.alpha_composite(over, "invalid source") + source.alpha_composite(over, "invalid destination") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), "invalid destination") + source.alpha_composite(over, (0, 0), "invalid source") # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, 0) + source.alpha_composite(over, 0) # type: ignore[arg-type] with pytest.raises(ValueError): - source.alpha_composite(over, (0, 0), 0) + source.alpha_composite(over, (0, 0), 0) # type: ignore[arg-type] with pytest.raises(ValueError): source.alpha_composite(over, (0, 0), (0, -1)) diff --git a/Tests/test_image_draft.py b/Tests/test_image_draft.py index 1ce1a7cd8..c9d8c93f3 100644 --- a/Tests/test_image_draft.py +++ b/Tests/test_image_draft.py @@ -16,7 +16,9 @@ def draft_roundtrip( im = Image.new(in_mode, in_size) data = tostring(im, "JPEG") im = fromstring(data) - mode, box = im.draft(req_mode, req_size) + result = im.draft(req_mode, req_size) + assert result is not None + box = result[1] scale, _ = im.decoderconfig assert box[:2] == (0, 0) assert (im.width - scale) < box[2] <= im.width diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index d8f6b65e0..01cb880cb 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -338,3 +338,8 @@ class TestImagingPaste: im.copy().paste(im2) im.copy().paste(im2, (0, 0)) + + def test_incorrect_abbreviated_form(self) -> None: + im = Image.new("L", (1, 1)) + with pytest.raises(ValueError): + im.paste(im, im, im) diff --git a/Tests/test_image_point.py b/Tests/test_image_point.py index 05f209351..a5d5a15db 100644 --- a/Tests/test_image_point.py +++ b/Tests/test_image_point.py @@ -61,4 +61,4 @@ def test_f_lut() -> None: def test_f_mode() -> None: im = hopper("F") with pytest.raises(ValueError): - im.point(None) + im.point([]) diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index 5e57e4c4c..dad26ef14 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -113,13 +113,13 @@ def test_array_F() -> None: def test_not_flattened() -> None: im = Image.new("L", (1, 1)) with pytest.raises(TypeError): - im.putdata([[0]]) # type: ignore[list-item] + im.putdata([[0]]) with pytest.raises(TypeError): - im.putdata([[0]], 2) # type: ignore[list-item] + im.putdata([[0]], 2) with pytest.raises(TypeError): im = Image.new("I", (1, 1)) - im.putdata([[0]]) # type: ignore[list-item] + im.putdata([[0]]) with pytest.raises(TypeError): im = Image.new("F", (1, 1)) - im.putdata([[0]]) # type: ignore[list-item] + im.putdata([[0]]) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index 9b3bdf330..d6055b577 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -445,7 +445,7 @@ class TestCoreResampleBox: im.resize((32, 32), resample, (20, 20, 100, 20)) with pytest.raises(TypeError, match="must be sequence of length 4"): - im.resize((32, 32), resample, (im.width, im.height)) + im.resize((32, 32), resample, (im.width, im.height)) # type: ignore[arg-type] with pytest.raises(ValueError, match="can't be negative"): im.resize((32, 32), resample, (-20, 20, 100, 100)) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 55f72c3b9..8a25460d3 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -103,7 +103,7 @@ def test_sanity() -> None: def test_flags() -> None: - assert ImageCms.Flags.NONE == 0 + assert ImageCms.Flags.NONE.value == 0 assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE @@ -569,9 +569,9 @@ def assert_aux_channel_preserved( for delta in nine_grid_deltas: channel_data.paste( channel_pattern, - tuple( - paste_offset[c] + delta[c] * channel_pattern.size[c] - for c in range(2) + ( + paste_offset[0] + delta[0] * channel_pattern.size[0], + paste_offset[1] + delta[1] * channel_pattern.size[1], ), ) chans.append(channel_data) @@ -642,7 +642,8 @@ def test_auxiliary_channels_isolated() -> None: # convert with and without AUX data, test colors are equal src_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], src_format[1]) source_profile = ImageCms.createProfile(src_colorSpace) - destination_profile = ImageCms.createProfile(dst_format[1]) + dst_colorSpace = cast(Literal["LAB", "XYZ", "sRGB"], dst_format[1]) + destination_profile = ImageCms.createProfile(dst_colorSpace) source_image = src_format[3] test_transform = ImageCms.buildTransform( source_profile, diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 8b55df992..a24aa16ca 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1581,7 +1581,7 @@ def test_compute_regular_polygon_vertices_input_error_handling( error_message: str, ) -> None: with pytest.raises(expected_error) as e: - ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) + ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) # type: ignore[arg-type] assert str(e.value) == error_message diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 3171eb9ae..35fe0cf40 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -51,9 +51,10 @@ def test_sanity() -> None: pen = ImageDraw2.Pen("blue", width=7) draw.line(list(range(10)), pen) - draw, handler = ImageDraw.getdraw(im) + draw2, handler = ImageDraw.getdraw(im) + assert draw2 is not None pen = ImageDraw2.Pen("blue", width=7) - draw.line(list(range(10)), pen) + draw2.line(list(range(10)), pen) @pytest.mark.parametrize("bbox", BBOX) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index c9dba2943..00cdec952 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -381,7 +381,7 @@ class TestPyEncoder(CodecsTest): def test_encode(self) -> None: encoder = ImageFile.PyEncoder(None) with pytest.raises(NotImplementedError): - encoder.encode(None) + encoder.encode(0) bytes_consumed, errcode = encoder.encode_to_pyfd() assert bytes_consumed == 0 diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 73cad513e..9f5c3984b 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -209,7 +209,7 @@ def test_getlength( assert length == length_raqm -def test_float_size() -> None: +def test_float_size(layout_engine: ImageFont.Layout) -> None: lengths = [] for size in (48, 48.5, 49): f = ImageFont.truetype( @@ -494,8 +494,8 @@ def test_default_font() -> None: assert_image_equal_tofile(im, "Tests/images/default_font_freetype.png") -@pytest.mark.parametrize("mode", (None, "1", "RGBA")) -def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: +@pytest.mark.parametrize("mode", ("", "1", "RGBA")) +def test_getbbox(font: ImageFont.FreeTypeFont, mode: str) -> None: assert (0, 4, 12, 16) == font.getbbox("A", mode) diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 3b1c14b4e..c4a39d1fa 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -14,7 +14,7 @@ original_core = ImageFont.core def setup_module() -> None: if features.check_module("freetype2"): - ImageFont.core = _util.DeferredError(ImportError) + ImageFont.core = _util.DeferredError(ImportError("Disabled for testing")) def teardown_module() -> None: @@ -76,3 +76,8 @@ def test_oom() -> None: font = ImageFont.ImageFont() font._load_pilfont_data(fp, Image.new("L", (1, 1))) font.getmask("A" * 1_000_000) + + +def test_freetypefont_without_freetype() -> None: + with pytest.raises(ImportError): + ImageFont.truetype("Tests/fonts/FreeMono.ttf") diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 8e2db15aa..17f68710a 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -45,7 +45,7 @@ def test_getcolor() -> None: # Test unknown color specifier with pytest.raises(ValueError): - palette.getcolor("unknown") + palette.getcolor("unknown") # type: ignore[arg-type] def test_getcolor_rgba_color_rgb_palette() -> None: diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 64dfb2c95..c3afa9089 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -54,14 +54,10 @@ def test_stdout(buffer: bool) -> None: # Temporarily redirect stdout old_stdout = sys.stdout - if buffer: + class MyStdOut: + buffer = BytesIO() - class MyStdOut: - buffer = BytesIO() - - mystdout = MyStdOut() - else: - mystdout = BytesIO() + mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() sys.stdout = mystdout @@ -71,6 +67,6 @@ def test_stdout(buffer: bool) -> None: # Reset stdout sys.stdout = old_stdout - if buffer: + if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer assert mystdout.getvalue() != b"" diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 57da58522..6e2f38650 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1168,7 +1168,7 @@ class Image: def quantize( self, colors: int = 256, - method: Quantize | None = None, + method: int | None = None, kmeans: int = 0, palette=None, dither: Dither = Dither.FLOYDSTEINBERG, @@ -1309,7 +1309,7 @@ class Image: return im.crop((x0, y0, x1, y1)) def draft( - self, mode: str | None, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: """ Configures the image file loader so it returns a version of the @@ -1744,7 +1744,7 @@ class Image: def paste( self, im: Image | str | float | tuple[float, ...], - box: tuple[int, int, int, int] | tuple[int, int] | None = None, + box: Image | tuple[int, int, int, int] | tuple[int, int] | None = None, mask: Image | None = None, ) -> None: """ @@ -1786,10 +1786,14 @@ class Image: :param mask: An optional mask image. """ - if isImageType(box) and mask is None: + if isImageType(box): + if mask is not None: + msg = "If using second argument as mask, third argument must be None" + raise ValueError(msg) # abbreviated paste(im, mask) syntax mask = box box = None + assert not isinstance(box, Image) if box is None: box = (0, 0) @@ -1995,7 +1999,10 @@ class Image: self.im.putband(alpha.im, band) def putdata( - self, data: Sequence[float], scale: float = 1.0, offset: float = 0.0 + self, + data: Sequence[float] | Sequence[Sequence[int]], + scale: float = 1.0, + offset: float = 0.0, ) -> None: """ Copies pixel data from a flattened sequence object into the image. The @@ -2656,7 +2663,7 @@ class Image: self, size: tuple[float, float], resample: Resampling = Resampling.BICUBIC, - reducing_gap: float = 2.0, + reducing_gap: float | None = 2.0, ) -> None: """ Make this image into a thumbnail. This method modifies the @@ -2717,11 +2724,12 @@ class Image: return x, y box = None + final_size: tuple[int, int] if reducing_gap is not None: preserved_size = preserve_aspect_ratio() if preserved_size is None: return - size = preserved_size + final_size = preserved_size res = self.draft( None, (int(size[0] * reducing_gap), int(size[1] * reducing_gap)) @@ -2735,13 +2743,13 @@ class Image: preserved_size = preserve_aspect_ratio() if preserved_size is None: return - size = preserved_size + final_size = preserved_size - if self.size != size: - im = self.resize(size, resample, box=box, reducing_gap=reducing_gap) + if self.size != final_size: + im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap) self.im = im.im - self._size = size + self._size = final_size self._mode = self.im.mode self.readonly = 0 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 91f898838..a471a7ffd 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -62,7 +62,9 @@ directly. class ImageDraw: - font = None + font: ( + ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont | None + ) = None def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index fa5608e6c..61e7a7abe 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,11 +33,12 @@ import sys import warnings from enum import IntEnum from io import BytesIO +from types import ModuleType from typing import IO, TYPE_CHECKING, Any, BinaryIO from . import Image from ._typing import StrOrBytesPath -from ._util import is_path +from ._util import DeferredError, is_path if TYPE_CHECKING: from . import ImageFile @@ -53,11 +54,10 @@ 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.new(ex) @@ -199,6 +199,7 @@ class FreeTypeFont: """FreeType font wrapper (requires _imagingft service)""" font: Font + font_bytes: bytes def __init__( self, @@ -210,6 +211,9 @@ class FreeTypeFont: ) -> None: # FIXME: use service provider instead + if isinstance(core, DeferredError): + raise core.ex + if size <= 0: msg = "font size must be greater than 0" raise ValueError(msg) @@ -903,7 +907,7 @@ def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: :return: A font object. """ f: FreeTypeFont | ImageFont - if core.__class__.__name__ == "module" or size is not None: + if isinstance(core, ModuleType) or size is not None: f = truetype( BytesIO( base64.b64decode( diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 293ba4941..a0c16b765 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -196,7 +196,7 @@ if qt_is_installed: self.setColorTable(im_data["colortable"]) -def toqimage(im): +def toqimage(im) -> ImageQt: return ImageQt(im) diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 80a6116b7..ffd791674 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -24,7 +24,7 @@ class Transform(Image.ImageTransformHandler): method: Image.Transform - def __init__(self, data: Sequence[int]) -> None: + def __init__(self, data: Sequence[Any]) -> None: self.data = data def getdata(self) -> tuple[Image.Transform, Sequence[int]]: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 7a32f1cde..9d6811cb7 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -428,7 +428,7 @@ class JpegImageFile(ImageFile.ImageFile): return s def draft( - self, mode: str | None, size: tuple[int, int] + self, mode: str | None, size: tuple[int, int] | None ) -> tuple[str, tuple[int, int, float, float]] | None: if len(self.tile) != 1: return None diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 89fad7033..e318c8739 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -89,7 +89,7 @@ DOUBLE = 12 IFD = 13 LONG8 = 16 -TAGS_V2 = { +_tags_v2 = { 254: ("NewSubfileType", LONG, 1), 255: ("SubfileType", SHORT, 1), 256: ("ImageWidth", LONG, 1), @@ -425,9 +425,11 @@ TAGS = { 50784: "Alias Layer Metadata", } +TAGS_V2: dict[int, TagInfo] = {} + def _populate(): - for k, v in TAGS_V2.items(): + for k, v in _tags_v2.items(): # Populate legacy structure. TAGS[k] = v[0] if len(v) == 4: From a2c0a90c44db86c78ccd2cb7ed1a08829e5389bb Mon Sep 17 00:00:00 2001 From: Yay295 Date: Mon, 17 Jun 2024 01:23:36 -0500 Subject: [PATCH 81/91] check required EPS header comments at end of file, not always --- src/PIL/EpsImagePlugin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 380b1cf0e..c7f9625c9 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -230,6 +230,11 @@ class EpsImageFile(ImageFile.ImageFile): trailer_reached = False def check_required_header_comments() -> None: + """ + The EPS spec requires that some headers exist. + This should be checked after all headers have been read, + or at the end of the file if that comes first. + """ if "PS-Adobe" not in self.info: msg = 'EPS header missing "%!PS-Adobe" comment' raise SyntaxError(msg) @@ -270,6 +275,8 @@ class EpsImageFile(ImageFile.ImageFile): 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 @@ -365,8 +372,6 @@ class EpsImageFile(ImageFile.ImageFile): trailer_reached = True bytes_read = 0 - check_required_header_comments() - if not self._size: msg = "cannot determine EPS bounding box" raise OSError(msg) From 00c9448cb0610dd957789de402c331ae3000dc5a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 23 Jun 2024 21:37:41 +1000 Subject: [PATCH 82/91] Update CHANGES.rst [ci skip] --- CHANGES.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d7231ebea..ec3073446 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,21 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Support unpacking more rawmodes to RGBA palettes #7966 + [radarhere] + +- Removed support for Qt 5 #8159 + [radarhere] + +- Improve ``ImageFont.freetype`` support for XDG directories on Linux #8135 + [mamg22, radarhere] + +- Improved consistency of XMP handling #8069 + [radarhere] + +- Use pkg-config to help find libwebp and raqm #8142 + [radarhere] + - Accept 't' suffix for libtiff version #8126, #8129 [radarhere] From 42381aa1e65735f67f4abbae170d97082f212de5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 24 Jun 2024 06:59:00 +1000 Subject: [PATCH 83/91] Added type hints --- Tests/test_file_bufrstub.py | 7 +++++-- Tests/test_file_gribstub.py | 7 +++++-- Tests/test_file_hdf5stub.py | 7 +++++-- Tests/test_file_png.py | 2 +- Tests/test_file_ppm.py | 2 +- Tests/test_file_psd.py | 2 +- Tests/test_file_tiff_metadata.py | 10 +++++++--- Tests/test_file_webp.py | 5 ++++- Tests/test_image.py | 16 ++++++++++++---- Tests/test_image_array.py | 7 +++++-- Tests/test_imagefile.py | 4 +++- Tests/test_imagefont.py | 2 +- Tests/test_imagewin_pointers.py | 11 ++++++----- Tests/test_psdraw.py | 2 +- 14 files changed, 57 insertions(+), 27 deletions(-) diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 939e82e77..77ee5b0ea 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None: BufrStubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.bufr") im.save(temp_file) diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 86a9064fc..aba473d24 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -64,6 +64,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -71,10 +74,10 @@ def test_handler(tmp_path: Path) -> None: GribStubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.grib") im.save(temp_file) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index ee1544c51..8275bd0d8 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -66,6 +66,9 @@ def test_handler(tmp_path: Path) -> None: im.fp.close() return Image.new("RGB", (1, 1)) + def is_loaded(self) -> bool: + return self.loaded + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True @@ -73,10 +76,10 @@ def test_handler(tmp_path: Path) -> None: Hdf5StubImagePlugin.register_handler(handler) with Image.open(TEST_FILE) as im: assert handler.opened - assert not handler.loaded + assert not handler.is_loaded() im.load() - assert handler.loaded + assert handler.is_loaded() temp_file = str(tmp_path / "temp.h5") im.save(temp_file) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 0f0976802..a8b50a6d4 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -775,7 +775,7 @@ class TestFilePng: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] with Image.open(TEST_PNG_FILE) as im: im.save(sys.stdout, "PNG") diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 0a61830a4..d6451ec18 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -373,7 +373,7 @@ def test_save_stdout(buffer: bool) -> None: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] with Image.open(TEST_FILE) as im: im.save(sys.stdout, "PPM") diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 484a1be8f..ddae8f235 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -162,7 +162,7 @@ def test_combined_larger_than_size() -> None: ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), ], ) -def test_crashes(test_file: str, raises) -> None: +def test_crashes(test_file: str, raises: type[Exception]) -> None: with open(test_file, "rb") as f: with pytest.raises(raises): with Image.open(f): diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index 8b816aa4f..1e0310001 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -11,7 +11,11 @@ from PIL.TiffImagePlugin import IFDRational from .helper import assert_deep_equal, hopper -TAG_IDS = {info.name: info.value for info in TiffTags.TAGS_V2.values()} +TAG_IDS: dict[str, int] = { + info.name: info.value + for info in TiffTags.TAGS_V2.values() + if info.value is not None +} def test_rt_metadata(tmp_path: Path) -> None: @@ -411,8 +415,8 @@ def test_empty_values() -> None: info = TiffImagePlugin.ImageFileDirectory_v2(head) info.load(data) # Should not raise ValueError. - info = dict(info) - assert 33432 in info + info_dict = dict(info) + assert 33432 in info_dict def test_photoshop_info(tmp_path: Path) -> None: diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 1caf032f6..cbc905de4 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -5,6 +5,7 @@ import re import sys import warnings from pathlib import Path +from typing import Any import pytest @@ -70,7 +71,9 @@ class TestFileWebp: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0) - def _roundtrip(self, tmp_path: Path, mode, epsilon, args={}) -> None: + def _roundtrip( + self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {} + ) -> None: temp_file = str(tmp_path / "temp.webp") hopper(mode).save(temp_file, **args) diff --git a/Tests/test_image.py b/Tests/test_image.py index a8f61d677..5b1ba6289 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -8,7 +8,7 @@ import sys import tempfile import warnings from pathlib import Path -from typing import IO +from typing import IO, Any import pytest @@ -175,11 +175,19 @@ class TestImage: def test_fp_name(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.jpg") - class FP: + class FP(io.BytesIO): name: str - def write(self, b: bytes) -> None: - pass + if sys.version_info >= (3, 12): + from collections.abc import Buffer + + def write(self, data: Buffer) -> int: + return len(data) + + else: + + def write(self, data: Any) -> int: + return len(data) fp = FP() fp.name = temp_file diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index d7e6c562c..bb6064882 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from packaging.version import parse as parse_version @@ -13,13 +13,16 @@ numpy = pytest.importorskip("numpy", reason="NumPy not installed") im = hopper().resize((128, 100)) +if TYPE_CHECKING: + import numpy.typing as npt + def test_toarray() -> None: def test(mode: str) -> tuple[tuple[int, ...], str, int]: ai = numpy.array(im.convert(mode)) return ai.shape, ai.dtype.str, ai.nbytes - def test_with_dtype(dtype) -> None: + def test_with_dtype(dtype: npt.DTypeLike) -> None: ai = numpy.array(im, dtype=dtype) assert ai.dtype == dtype diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 00cdec952..a269b846a 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -351,7 +351,9 @@ class TestPyEncoder(CodecsTest): ImageFile._save( im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] ) - assert MockPyEncoder.last.cleanup_called + last: MockPyEncoder | None = MockPyEncoder.last + assert last + assert last.cleanup_called with pytest.raises(ValueError): ImageFile._save( diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index c3906de24..e50198087 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -548,7 +548,7 @@ def test_find_font( def loadable_font( filepath: str, size: int, index: int, encoding: str, *args: Any - ): + ) -> ImageFont.FreeTypeFont: _freeTypeFont = getattr(ImageFont, "_FreeTypeFont") if filepath == path_to_fake: return _freeTypeFont(FONT_PATH, size, index, encoding, *args) diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index e6c312a0c..c23a5c690 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -45,21 +45,22 @@ if is_win32(): memcpy = ctypes.cdll.msvcrt.memcpy memcpy.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t] - CreateCompatibleDC = ctypes.windll.gdi32.CreateCompatibleDC + windll = getattr(ctypes, "windll") + CreateCompatibleDC = windll.gdi32.CreateCompatibleDC CreateCompatibleDC.argtypes = [ctypes.wintypes.HDC] CreateCompatibleDC.restype = ctypes.wintypes.HDC - DeleteDC = ctypes.windll.gdi32.DeleteDC + DeleteDC = windll.gdi32.DeleteDC DeleteDC.argtypes = [ctypes.wintypes.HDC] - SelectObject = ctypes.windll.gdi32.SelectObject + SelectObject = windll.gdi32.SelectObject SelectObject.argtypes = [ctypes.wintypes.HDC, ctypes.wintypes.HGDIOBJ] SelectObject.restype = ctypes.wintypes.HGDIOBJ - DeleteObject = ctypes.windll.gdi32.DeleteObject + DeleteObject = windll.gdi32.DeleteObject DeleteObject.argtypes = [ctypes.wintypes.HGDIOBJ] - CreateDIBSection = ctypes.windll.gdi32.CreateDIBSection + CreateDIBSection = windll.gdi32.CreateDIBSection CreateDIBSection.argtypes = [ ctypes.wintypes.HDC, ctypes.c_void_p, diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index c3afa9089..130ffa863 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -59,7 +59,7 @@ def test_stdout(buffer: bool) -> None: mystdout: MyStdOut | BytesIO = MyStdOut() if buffer else BytesIO() - sys.stdout = mystdout + sys.stdout = mystdout # type: ignore[assignment] ps = PSDraw.PSDraw() _create_document(ps) From e5c4d56a9e62ab5834350ef1e39da542ea1559d5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 24 Jun 2024 15:08:36 +1000 Subject: [PATCH 84/91] Clarify error message when size is missing --- Tests/test_imagedraw2.py | 8 ++++++++ src/PIL/ImageDraw2.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index 35fe0cf40..c80aa739c 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -57,6 +57,14 @@ def test_sanity() -> None: draw2.line(list(range(10)), pen) +def test_mode() -> None: + draw = ImageDraw2.Draw("L", (1, 1)) + assert draw.image.mode == "L" + + with pytest.raises(ValueError): + ImageDraw2.Draw("L") + + @pytest.mark.parametrize("bbox", BBOX) def test_ellipse(bbox: Coords) -> None: # Arrange diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index b42f5d9ea..600e140e1 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -56,8 +56,16 @@ 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 From f2302ab716e9ee4e45bca3dee2c83ce5f56283b8 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 24 Jun 2024 21:04:33 +1000 Subject: [PATCH 85/91] Added type hints --- Tests/test_file_spider.py | 1 + Tests/test_imagefile.py | 4 +-- Tests/test_imageops_usm.py | 2 +- Tests/test_imagepalette.py | 8 +++--- src/PIL/BlpImagePlugin.py | 2 ++ src/PIL/ImageDraw2.py | 7 +++++- src/PIL/ImageFile.py | 25 ++++++++++--------- src/PIL/ImagePalette.py | 48 ++++++++++++++++++++++-------------- src/PIL/ImageWin.py | 13 ++++++---- src/PIL/SpiderImagePlugin.py | 22 +++++++++-------- 10 files changed, 79 insertions(+), 53 deletions(-) diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 9b82a962a..66c88e9d8 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -105,6 +105,7 @@ def test_load_image_series() -> None: img_list = SpiderImagePlugin.loadImageSeries(file_list) # Assert + assert img_list is not None assert len(img_list) == 1 assert isinstance(img_list[0], Image.Image) assert img_list[0].size == (128, 128) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index a269b846a..68b28ef07 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -209,7 +209,7 @@ class MockPyDecoder(ImageFile.PyDecoder): super().__init__(mode, *args) - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: # eof return -1, 0 @@ -222,7 +222,7 @@ class MockPyEncoder(ImageFile.PyEncoder): super().__init__(mode, *args) - def encode(self, buffer): + def encode(self, bufsize: int) -> tuple[int, int, bytes]: return 1, 1, b"" def cleanup(self) -> None: diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index dbdd5b317..c8e2c0467 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -101,7 +101,7 @@ def test_blur_accuracy(test_images: dict[str, ImageFile.ImageFile]) -> None: assert i.im.getpixel((x, y))[c] >= 250 # Fuzzy match. - def gp(x, y): + def gp(x: int, y: int) -> tuple[int, ...]: return i.im.getpixel((x, y)) assert 236 <= gp(7, 4)[0] <= 239 diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index 17f68710a..6cf0079dd 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -88,13 +88,13 @@ def test_file(tmp_path: Path) -> None: palette.save(f) - p = ImagePalette.load(f) + lut = ImagePalette.load(f) # load returns raw palette information - assert len(p[0]) == 768 - assert p[1] == "RGB" + assert len(lut[0]) == 768 + assert lut[1] == "RGB" - p = ImagePalette.raw(p[1], p[0]) + p = ImagePalette.raw(lut[1], lut[0]) assert isinstance(p, ImagePalette.ImagePalette) assert p.palette == palette.tobytes() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index 59246c6e2..b9cefafdd 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -430,6 +430,7 @@ class BLPEncoder(ImageFile.PyEncoder): 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] @@ -444,6 +445,7 @@ class BLPEncoder(ImageFile.PyEncoder): 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)) diff --git a/src/PIL/ImageDraw2.py b/src/PIL/ImageDraw2.py index 600e140e1..e89a78be4 100644 --- a/src/PIL/ImageDraw2.py +++ b/src/PIL/ImageDraw2.py @@ -24,7 +24,10 @@ """ from __future__ import annotations +from typing import BinaryIO + from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath +from ._typing import StrOrBytesPath class Pen: @@ -45,7 +48,9 @@ class Brush: 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) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 5d67409ea..69e7ee548 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -65,7 +65,7 @@ Dict of known error codes returned from :meth:`.PyDecoder.decode`, # Helpers -def _get_oserror(error, *, encoder): +def _get_oserror(error: int, *, encoder: bool) -> OSError: try: msg = Image.core.getcodecstatus(error) except AttributeError: @@ -76,7 +76,7 @@ def _get_oserror(error, *, encoder): return OSError(msg) -def raise_oserror(error): +def raise_oserror(error: int) -> OSError: deprecate( "raise_oserror", 12, @@ -154,11 +154,12 @@ class ImageFile(Image.Image): self.fp.close() raise - def get_format_mimetype(self): + 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): self.tile = [] @@ -365,7 +366,7 @@ class StubImageFile(ImageFile): certain format, but relies on external code to load the file. """ - def _open(self): + def _open(self) -> None: msg = "StubImageFile subclass must implement _open" raise NotImplementedError(msg) @@ -381,7 +382,7 @@ class StubImageFile(ImageFile): self.__dict__ = image.__dict__ return image.load() - def _load(self): + def _load(self) -> StubHandler | None: """(Hook) Find actual image loader.""" msg = "StubImageFile subclass must implement _load" raise NotImplementedError(msg) @@ -621,7 +622,7 @@ class PyCodecState: 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 @@ -661,7 +662,7 @@ class PyCodec: """ self.fd = fd - def setimage(self, im, extents=None): + def setimage(self, im, extents: tuple[int, int, int, int] | None = None) -> None: """ Called from ImageFile to set the core output image for the codec @@ -710,10 +711,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) -> tuple[int, int]: """ Override to perform the decoding process. @@ -738,6 +739,7 @@ class PyDecoder(PyCodec): if not rawmode: rawmode = self.mode d = Image._getdecoder(self.mode, "raw", rawmode) + assert self.im is not None d.setimage(self.im, self.state.extents()) s = d.decode(data) @@ -760,7 +762,7 @@ class PyEncoder(PyCodec): _pushes_fd = False @property - def pushes_fd(self): + def pushes_fd(self) -> bool: return self._pushes_fd def encode(self, bufsize: int) -> tuple[int, int, bytes]: @@ -775,7 +777,7 @@ class PyEncoder(PyCodec): 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. @@ -787,6 +789,7 @@ 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 diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index 6473c4577..ed38285dc 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -38,23 +38,27 @@ class ImagePalette: Defaults to an empty palette. """ - def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> 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: 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) -> dict[tuple[int, int, int] | tuple[int, int, int, int], int]: + def colors(self) -> dict[tuple[int, ...], int]: if self._colors is None: mode_len = len(self.mode) self._colors = {} @@ -66,9 +70,7 @@ class ImagePalette: return self._colors @colors.setter - def colors( - self, colors: dict[tuple[int, int, int] | tuple[int, int, int, int], int] - ) -> None: + def colors(self, colors: dict[tuple[int, ...], int]) -> None: self._colors = colors def copy(self) -> ImagePalette: @@ -82,7 +84,7 @@ class ImagePalette: return new - def getdata(self) -> tuple[str, bytes]: + def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]: """ Get palette contents in format suitable for the low-level ``im.putpalette`` primitive. @@ -137,7 +139,7 @@ class ImagePalette: def getcolor( self, - color: tuple[int, int, int] | tuple[int, int, int, int], + color: tuple[int, ...], image: Image.Image | None = None, ) -> int: """Given an rgb tuple, allocate palette entry. @@ -162,12 +164,13 @@ class ImagePalette: except KeyError as e: # allocate new color slot 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) @@ -204,7 +207,7 @@ class ImagePalette: # Internal -def raw(rawmode, data) -> ImagePalette: +def raw(rawmode, data: Sequence[int] | bytes | bytearray) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data @@ -216,9 +219,9 @@ def raw(rawmode, data) -> ImagePalette: # Factories -def make_linear_lut(black, white): +def make_linear_lut(black: int, white: float) -> list[int]: if black == 0: - return [white * i // 255 for i in range(256)] + return [int(white * i // 255) for i in range(256)] msg = "unavailable when black is non-zero" raise NotImplementedError(msg) # FIXME @@ -251,15 +254,22 @@ def wedge(mode: str = "RGB") -> ImagePalette: 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() diff --git a/src/PIL/ImageWin.py b/src/PIL/ImageWin.py index 6c29e2590..978c5a9d1 100644 --- a/src/PIL/ImageWin.py +++ b/src/PIL/ImageWin.py @@ -69,19 +69,22 @@ 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] | list[int] | None = None + ) -> None: + if isinstance(image, str): + mode = image + image = "" + 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): diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index a6cc00019..f5a09c3ef 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -37,12 +37,12 @@ from __future__ import annotations import os import struct import sys -from typing import IO, TYPE_CHECKING +from typing import IO, TYPE_CHECKING, Any, Tuple, cast from . import Image, ImageFile -def isInt(f): +def isInt(f: Any) -> int: try: i = int(f) if f - i == 0: @@ -62,7 +62,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]: @@ -82,7 +82,7 @@ def isSpiderHeader(t): return labbyt -def isSpiderImage(filename): +def isSpiderImage(filename: str) -> int: with open(filename, "rb") as fp: f = fp.read(92) # read 23 * 4 bytes t = struct.unpack(">23f", f) # try big-endian first @@ -184,13 +184,15 @@ class SpiderImageFile(ImageFile.ImageFile): 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 @@ -207,10 +209,10 @@ 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[SpiderImageFile] | 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 = [] for img in filelist: From 065aeaea472b38896d43363095cccc05220b3c9c Mon Sep 17 00:00:00 2001 From: Yay295 Date: Mon, 24 Jun 2024 07:28:47 -0500 Subject: [PATCH 86/91] improve check_required_header_comments() description Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/EpsImagePlugin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index c7f9625c9..f31b1c1a2 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -231,9 +231,9 @@ class EpsImageFile(ImageFile.ImageFile): def check_required_header_comments() -> None: """ - The EPS spec requires that some headers exist. - This should be checked after all headers have been read, - or at the end of the file if that comes first. + 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' From a941f096aa06df7d71dc451151076925dc8dc9f9 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 25 Jun 2024 18:49:40 +1000 Subject: [PATCH 87/91] Update CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index ec3073446..9c731fe94 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Clarify ImageDraw2 error message when size is missing #8165 + [radarhere] + - Support unpacking more rawmodes to RGBA palettes #7966 [radarhere] From ded404507bf8e8ae33ee194e9e1dc275e25edc82 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 22 Jun 2024 17:02:19 +1000 Subject: [PATCH 88/91] Removed ignores --- src/PIL/ImageGrab.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index 96a28bb35..aa53abd20 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -22,7 +22,6 @@ import shutil import subprocess import sys import tempfile -from typing import Union, cast from . import Image @@ -70,15 +69,15 @@ def grab( left, top, right, bottom = bbox im = im.crop((left - x0, top - y0, right - x0, bottom - y0)) return im - xdisplay = cast(Union[str, None], xdisplay) # type: ignore[redundant-cast, unused-ignore] + 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 + display_name is None and sys.platform not in ("darwin", "win32") and shutil.which("gnome-screenshot") ): From d0d53d4bac853acaad51af7d6aa9582cad59945a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 22 Jun 2024 17:02:28 +1000 Subject: [PATCH 89/91] Added type hints to tests --- Tests/test_imagegrab.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index e23adeb70..5dfa51697 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -89,6 +89,7 @@ $bmp = New-Object Drawing.Bitmap 200, 200 p.communicate() im = ImageGrab.grabclipboard() + assert isinstance(im, list) assert len(im) == 1 assert os.path.samefile(im[0], "Tests/images/hopper.gif") @@ -105,6 +106,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) p.communicate() im = ImageGrab.grabclipboard() + assert isinstance(im, Image.Image) assert_image_equal_tofile(im, "Tests/images/hopper.png") @pytest.mark.skipif( @@ -120,6 +122,7 @@ $ms = new-object System.IO.MemoryStream(, $bytes) with open(image_path, "rb") as fp: subprocess.call(["wl-copy"], stdin=fp) im = ImageGrab.grabclipboard() + assert isinstance(im, Image.Image) assert_image_equal_tofile(im, image_path) @pytest.mark.skipif( From ab183958188e38b6501410b4f34a561fbea0ba56 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 25 Jun 2024 07:13:17 +1000 Subject: [PATCH 90/91] Added comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondrej Baranovič --- src/PIL/ImageGrab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index aa53abd20..e27ca7e50 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -69,6 +69,7 @@ def grab( 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: From e7c1da3cc8a26b821e0a4ec2d4651d62271f00c5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 25 Jun 2024 21:42:25 +1000 Subject: [PATCH 91/91] Update CHANGES.rst [ci skip] --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 9c731fe94..6375f5c63 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,12 @@ Changelog (Pillow) 10.4.0 (unreleased) ------------------- +- Read IM and TIFF images as RGB, rather than RGBX #7997 + [radarhere] + +- Only preserve TIFF IPTC_NAA_CHUNK tag if type is BYTE or UNDEFINED #7948 + [radarhere] + - Clarify ImageDraw2 error message when size is missing #8165 [radarhere]