From ce4059171cc5696e7c7d8c5dc74990d5e874bf58 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 26 Oct 2024 18:41:05 +1100 Subject: [PATCH 01/25] Skip failing records when rendering --- Tests/test_file_wmf.py | 12 +++++++++++- src/display.c | 13 +++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 79e707263..d730a049a 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,5 +1,6 @@ from __future__ import annotations +from io import BytesIO from pathlib import Path from typing import IO @@ -7,7 +8,7 @@ import pytest from PIL import Image, ImageFile, WmfImagePlugin -from .helper import assert_image_similar_tofile, hopper +from .helper import assert_image_equal_tofile, assert_image_similar_tofile, hopper def test_load_raw() -> None: @@ -34,6 +35,15 @@ def test_load() -> None: assert im.load()[0, 0] == (255, 255, 255) +def test_render() -> None: + with open("Tests/images/drawing.emf", "rb") as fp: + data = fp.read() + b = BytesIO(data[:808] + b"\x00" + data[809:]) + with Image.open(b) as im: + if hasattr(Image.core, "drawwmf"): + assert_image_equal_tofile(im, "Tests/images/drawing.emf") + + def test_register_handler(tmp_path: Path) -> None: class TestHandler(ImageFile.StubHandler): methodCalled = False diff --git a/src/display.c b/src/display.c index b4e2e3899..03b9316c3 100644 --- a/src/display.c +++ b/src/display.c @@ -716,6 +716,14 @@ PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { #define GET32(p, o) ((DWORD *)(p + o))[0] +BOOL +enhMetaFileProc( + HDC hdc, HANDLETABLE FAR *lpht, CONST ENHMETARECORD *lpmr, int nHandles, LPARAM data +) { + PlayEnhMetaFileRecord(hdc, lpht, lpmr, nHandles); + return TRUE; +} + PyObject * PyImaging_DrawWmf(PyObject *self, PyObject *args) { HBITMAP bitmap; @@ -796,10 +804,7 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) { /* FIXME: make background transparent? configurable? */ FillRect(dc, &rect, GetStockObject(WHITE_BRUSH)); - if (!PlayEnhMetaFile(dc, meta, &rect)) { - PyErr_SetString(PyExc_OSError, "cannot render metafile"); - goto error; - } + EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect); /* step 4: extract bits from bitmap */ From b4ba4665410c70ad8976b092ee9b1ad89625c0ed Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 27 Oct 2024 07:03:35 +1100 Subject: [PATCH 02/25] Do not skip failing records on 32-bit --- Tests/test_file_wmf.py | 2 ++ src/display.c | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 2adf38d48..e60a5b64e 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from io import BytesIO from pathlib import Path from typing import IO @@ -35,6 +36,7 @@ def test_load() -> None: assert im.load()[0, 0] == (255, 255, 255) +@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_render() -> None: with open("Tests/images/drawing.emf", "rb") as fp: data = fp.read() diff --git a/src/display.c b/src/display.c index 03b9316c3..fe5801fc0 100644 --- a/src/display.c +++ b/src/display.c @@ -716,12 +716,12 @@ PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { #define GET32(p, o) ((DWORD *)(p + o))[0] -BOOL +int enhMetaFileProc( - HDC hdc, HANDLETABLE FAR *lpht, CONST ENHMETARECORD *lpmr, int nHandles, LPARAM data + HDC hdc, HANDLETABLE *lpht, const ENHMETARECORD *lpmr, int nHandles, LPARAM data ) { PlayEnhMetaFileRecord(hdc, lpht, lpmr, nHandles); - return TRUE; + return 1; } PyObject * @@ -804,7 +804,14 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) { /* FIXME: make background transparent? configurable? */ FillRect(dc, &rect, GetStockObject(WHITE_BRUSH)); +#ifdef _WIN64 EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect); +#else + if (!PlayEnhMetaFile(dc, meta, &rect)) { + PyErr_SetString(PyExc_OSError, "cannot render metafile"); + goto error; + } +#endif /* step 4: extract bits from bitmap */ From 2ea3ea94a117772532e6f04ae7284c5842392af4 Mon Sep 17 00:00:00 2001 From: Nulano Date: Thu, 26 Dec 2024 21:41:51 +0100 Subject: [PATCH 03/25] Skip failing WMF records on 32-bit Windows --- Tests/test_file_wmf.py | 2 -- src/display.c | 9 +-------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 9322bd0c5..d3cad7ca4 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from io import BytesIO from pathlib import Path from typing import IO @@ -43,7 +42,6 @@ def test_load_zero_inch() -> None: pass -@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") def test_render() -> None: with open("Tests/images/drawing.emf", "rb") as fp: data = fp.read() diff --git a/src/display.c b/src/display.c index fe5801fc0..b5e9c2a3d 100644 --- a/src/display.c +++ b/src/display.c @@ -716,7 +716,7 @@ PyImaging_EventLoopWin32(PyObject *self, PyObject *args) { #define GET32(p, o) ((DWORD *)(p + o))[0] -int +static int CALLBACK enhMetaFileProc( HDC hdc, HANDLETABLE *lpht, const ENHMETARECORD *lpmr, int nHandles, LPARAM data ) { @@ -804,14 +804,7 @@ PyImaging_DrawWmf(PyObject *self, PyObject *args) { /* FIXME: make background transparent? configurable? */ FillRect(dc, &rect, GetStockObject(WHITE_BRUSH)); -#ifdef _WIN64 EnumEnhMetaFile(dc, meta, enhMetaFileProc, NULL, &rect); -#else - if (!PlayEnhMetaFile(dc, meta, &rect)) { - PyErr_SetString(PyExc_OSError, "cannot render metafile"); - goto error; - } -#endif /* step 4: extract bits from bitmap */ From acd8548f6e38b48c2af02d5081584472535afd52 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 20 Mar 2025 22:36:59 +1100 Subject: [PATCH 04/25] Removed FIXME --- src/PIL/Image.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 884659882..e2a76dfe1 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -548,7 +548,6 @@ class Image: def __init__(self) -> None: # FIXME: take "new" parameters / other image? - # FIXME: turn mode and size into delegating properties? self._im: core.ImagingCore | DeferredError | None = None self._mode = "" self._size = (0, 0) From 0888dc02ac9700acdaf5d07691203fb7e57a538a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 20 Mar 2025 23:10:09 +1100 Subject: [PATCH 05/25] Allow for two header fields and a comment --- Tests/images/full_gimp_palette.gpl | 260 +++++++++++++++++++++++++++++ Tests/test_file_gimppalette.py | 22 ++- src/PIL/GimpPaletteFile.py | 4 +- 3 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 Tests/images/full_gimp_palette.gpl diff --git a/Tests/images/full_gimp_palette.gpl b/Tests/images/full_gimp_palette.gpl new file mode 100644 index 000000000..004217210 --- /dev/null +++ b/Tests/images/full_gimp_palette.gpl @@ -0,0 +1,260 @@ +GIMP Palette +Name: fullpalette +Columns: 4 +# + 0 0 0 Index 0 + 1 1 1 Index 1 + 2 2 2 Index 2 + 3 3 3 Index 3 + 4 4 4 Index 4 + 5 5 5 Index 5 + 6 6 6 Index 6 + 7 7 7 Index 7 + 8 8 8 Index 8 + 9 9 9 Index 9 + 10 10 10 Index 10 + 11 11 11 Index 11 + 12 12 12 Index 12 + 13 13 13 Index 13 + 14 14 14 Index 14 + 15 15 15 Index 15 + 16 16 16 Index 16 + 17 17 17 Index 17 + 18 18 18 Index 18 + 19 19 19 Index 19 + 20 20 20 Index 20 + 21 21 21 Index 21 + 22 22 22 Index 22 + 23 23 23 Index 23 + 24 24 24 Index 24 + 25 25 25 Index 25 + 26 26 26 Index 26 + 27 27 27 Index 27 + 28 28 28 Index 28 + 29 29 29 Index 29 + 30 30 30 Index 30 + 31 31 31 Index 31 + 32 32 32 Index 32 + 33 33 33 Index 33 + 34 34 34 Index 34 + 35 35 35 Index 35 + 36 36 36 Index 36 + 37 37 37 Index 37 + 38 38 38 Index 38 + 39 39 39 Index 39 + 40 40 40 Index 40 + 41 41 41 Index 41 + 42 42 42 Index 42 + 43 43 43 Index 43 + 44 44 44 Index 44 + 45 45 45 Index 45 + 46 46 46 Index 46 + 47 47 47 Index 47 + 48 48 48 Index 48 + 49 49 49 Index 49 + 50 50 50 Index 50 + 51 51 51 Index 51 + 52 52 52 Index 52 + 53 53 53 Index 53 + 54 54 54 Index 54 + 55 55 55 Index 55 + 56 56 56 Index 56 + 57 57 57 Index 57 + 58 58 58 Index 58 + 59 59 59 Index 59 + 60 60 60 Index 60 + 61 61 61 Index 61 + 62 62 62 Index 62 + 63 63 63 Index 63 + 64 64 64 Index 64 + 65 65 65 Index 65 + 66 66 66 Index 66 + 67 67 67 Index 67 + 68 68 68 Index 68 + 69 69 69 Index 69 + 70 70 70 Index 70 + 71 71 71 Index 71 + 72 72 72 Index 72 + 73 73 73 Index 73 + 74 74 74 Index 74 + 75 75 75 Index 75 + 76 76 76 Index 76 + 77 77 77 Index 77 + 78 78 78 Index 78 + 79 79 79 Index 79 + 80 80 80 Index 80 + 81 81 81 Index 81 + 82 82 82 Index 82 + 83 83 83 Index 83 + 84 84 84 Index 84 + 85 85 85 Index 85 + 86 86 86 Index 86 + 87 87 87 Index 87 + 88 88 88 Index 88 + 89 89 89 Index 89 + 90 90 90 Index 90 + 91 91 91 Index 91 + 92 92 92 Index 92 + 93 93 93 Index 93 + 94 94 94 Index 94 + 95 95 95 Index 95 + 96 96 96 Index 96 + 97 97 97 Index 97 + 98 98 98 Index 98 + 99 99 99 Index 99 +100 100 100 Index 100 +101 101 101 Index 101 +102 102 102 Index 102 +103 103 103 Index 103 +104 104 104 Index 104 +105 105 105 Index 105 +106 106 106 Index 106 +107 107 107 Index 107 +108 108 108 Index 108 +109 109 109 Index 109 +110 110 110 Index 110 +111 111 111 Index 111 +112 112 112 Index 112 +113 113 113 Index 113 +114 114 114 Index 114 +115 115 115 Index 115 +116 116 116 Index 116 +117 117 117 Index 117 +118 118 118 Index 118 +119 119 119 Index 119 +120 120 120 Index 120 +121 121 121 Index 121 +122 122 122 Index 122 +123 123 123 Index 123 +124 124 124 Index 124 +125 125 125 Index 125 +126 126 126 Index 126 +127 127 127 Index 127 +128 128 128 Index 128 +129 129 129 Index 129 +130 130 130 Index 130 +131 131 131 Index 131 +132 132 132 Index 132 +133 133 133 Index 133 +134 134 134 Index 134 +135 135 135 Index 135 +136 136 136 Index 136 +137 137 137 Index 137 +138 138 138 Index 138 +139 139 139 Index 139 +140 140 140 Index 140 +141 141 141 Index 141 +142 142 142 Index 142 +143 143 143 Index 143 +144 144 144 Index 144 +145 145 145 Index 145 +146 146 146 Index 146 +147 147 147 Index 147 +148 148 148 Index 148 +149 149 149 Index 149 +150 150 150 Index 150 +151 151 151 Index 151 +152 152 152 Index 152 +153 153 153 Index 153 +154 154 154 Index 154 +155 155 155 Index 155 +156 156 156 Index 156 +157 157 157 Index 157 +158 158 158 Index 158 +159 159 159 Index 159 +160 160 160 Index 160 +161 161 161 Index 161 +162 162 162 Index 162 +163 163 163 Index 163 +164 164 164 Index 164 +165 165 165 Index 165 +166 166 166 Index 166 +167 167 167 Index 167 +168 168 168 Index 168 +169 169 169 Index 169 +170 170 170 Index 170 +171 171 171 Index 171 +172 172 172 Index 172 +173 173 173 Index 173 +174 174 174 Index 174 +175 175 175 Index 175 +176 176 176 Index 176 +177 177 177 Index 177 +178 178 178 Index 178 +179 179 179 Index 179 +180 180 180 Index 180 +181 181 181 Index 181 +182 182 182 Index 182 +183 183 183 Index 183 +184 184 184 Index 184 +185 185 185 Index 185 +186 186 186 Index 186 +187 187 187 Index 187 +188 188 188 Index 188 +189 189 189 Index 189 +190 190 190 Index 190 +191 191 191 Index 191 +192 192 192 Index 192 +193 193 193 Index 193 +194 194 194 Index 194 +195 195 195 Index 195 +196 196 196 Index 196 +197 197 197 Index 197 +198 198 198 Index 198 +199 199 199 Index 199 +200 200 200 Index 200 +201 201 201 Index 201 +202 202 202 Index 202 +203 203 203 Index 203 +204 204 204 Index 204 +205 205 205 Index 205 +206 206 206 Index 206 +207 207 207 Index 207 +208 208 208 Index 208 +209 209 209 Index 209 +210 210 210 Index 210 +211 211 211 Index 211 +212 212 212 Index 212 +213 213 213 Index 213 +214 214 214 Index 214 +215 215 215 Index 215 +216 216 216 Index 216 +217 217 217 Index 217 +218 218 218 Index 218 +219 219 219 Index 219 +220 220 220 Index 220 +221 221 221 Index 221 +222 222 222 Index 222 +223 223 223 Index 223 +224 224 224 Index 224 +225 225 225 Index 225 +226 226 226 Index 226 +227 227 227 Index 227 +228 228 228 Index 228 +229 229 229 Index 229 +230 230 230 Index 230 +231 231 231 Index 231 +232 232 232 Index 232 +233 233 233 Index 233 +234 234 234 Index 234 +235 235 235 Index 235 +236 236 236 Index 236 +237 237 237 Index 237 +238 238 238 Index 238 +239 239 239 Index 239 +240 240 240 Index 240 +241 241 241 Index 241 +242 242 242 Index 242 +243 243 243 Index 243 +244 244 244 Index 244 +245 245 245 Index 245 +246 246 246 Index 246 +247 247 247 Index 247 +248 248 248 Index 248 +249 249 249 Index 249 +250 250 250 Index 250 +251 251 251 Index 251 +252 252 252 Index 252 +253 253 253 Index 253 +254 254 254 Index 254 +255 255 255 Index 255 diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index ff9cc91c5..c122b37b3 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,5 +1,7 @@ from __future__ import annotations +from io import BytesIO + import pytest from PIL.GimpPaletteFile import GimpPaletteFile @@ -22,9 +24,12 @@ def test_sanity() -> None: GimpPaletteFile(fp) -def test_get_palette() -> None: +@pytest.mark.parametrize( + "filename, size", (("custom_gimp_palette.gpl", 8), ("full_gimp_palette.gpl", 256)) +) +def test_get_palette(filename: str, size: int) -> None: # Arrange - with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + with open("Tests/images/" + filename, "rb") as fp: palette_file = GimpPaletteFile(fp) # Act @@ -32,4 +37,15 @@ def test_get_palette() -> None: # Assert assert mode == "RGB" - assert len(palette) / 3 == 8 + assert len(palette) / 3 == size + + +def test_palette_limit() -> None: + with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: + data = fp.read() + + # Test that __init__ only reads 256 entries + data = data.replace(b"#\n", b"") + b" 0 0 0 Index 256" + b = BytesIO(data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 256 diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 0f079f457..a87487209 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -30,7 +30,7 @@ class GimpPaletteFile: raise SyntaxError(msg) palette: list[int] = [] - for _ in range(256): + for _ in range(256 + 3): s = fp.readline() if not s: break @@ -48,6 +48,8 @@ class GimpPaletteFile: raise ValueError(msg) palette += (int(v[i]) for i in range(3)) + if len(palette) == 768: + break self.palette = bytes(palette) From 510bc055774291d2380ea5d8e25faa84c7dfc637 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 20 Mar 2025 23:12:35 +1100 Subject: [PATCH 06/25] Added frombytes() to allow for unlimited parsing --- Tests/test_file_gimppalette.py | 24 +++++++++++++++++++----- src/PIL/GimpPaletteFile.py | 23 +++++++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index c122b37b3..c3d2bfeb7 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -16,11 +16,11 @@ def test_sanity() -> None: GimpPaletteFile(fp) with open("Tests/images/bad_palette_file.gpl", "rb") as fp: - with pytest.raises(SyntaxError): + with pytest.raises(SyntaxError, match="bad palette file"): GimpPaletteFile(fp) with open("Tests/images/bad_palette_entry.gpl", "rb") as fp: - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bad palette entry"): GimpPaletteFile(fp) @@ -40,12 +40,26 @@ def test_get_palette(filename: str, size: int) -> None: assert len(palette) / 3 == size -def test_palette_limit() -> None: +def test_frombytes() -> None: with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: - data = fp.read() + full_data = fp.read() # Test that __init__ only reads 256 entries - data = data.replace(b"#\n", b"") + b" 0 0 0 Index 256" + data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256" b = BytesIO(data) palette = GimpPaletteFile(b) assert len(palette.palette) / 3 == 256 + + # Test that frombytes() can read beyond that + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 257 + + # Test that __init__ raises an error if a comment is too long + data = full_data[:-1] + b"a" * 100 + b = BytesIO(data) + with pytest.raises(SyntaxError, match="bad palette file"): + palette = GimpPaletteFile(b) + + # Test that frombytes() can read the data regardless + palette = GimpPaletteFile.frombytes(data) + assert len(palette.palette) / 3 == 256 diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index a87487209..379ffd739 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -16,6 +16,7 @@ from __future__ import annotations import re +from io import BytesIO from typing import IO @@ -24,13 +25,18 @@ class GimpPaletteFile: rawmode = "RGB" - def __init__(self, fp: IO[bytes]) -> None: + def _read(self, fp: IO[bytes], limit: bool = True) -> None: if not fp.readline().startswith(b"GIMP Palette"): msg = "not a GIMP palette file" raise SyntaxError(msg) palette: list[int] = [] - for _ in range(256 + 3): + i = 0 + while True: + if limit and i == 256 + 3: + break + + i += 1 s = fp.readline() if not s: break @@ -38,7 +44,7 @@ class GimpPaletteFile: # skip fields and comment lines if re.match(rb"\w+:|#", s): continue - if len(s) > 100: + if limit and len(s) > 100: msg = "bad palette file" raise SyntaxError(msg) @@ -48,10 +54,19 @@ class GimpPaletteFile: raise ValueError(msg) palette += (int(v[i]) for i in range(3)) - if len(palette) == 768: + if limit and len(palette) == 768: break self.palette = bytes(palette) + def __init__(self, fp: IO[bytes]) -> None: + self._read(fp) + + @classmethod + def frombytes(cls, data: bytes) -> GimpPaletteFile: + self = cls.__new__(cls) + self._read(BytesIO(data), False) + return self + def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode From 21ff960c9c796892e5e13fa8ca2d382565141539 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Mar 2025 08:51:41 +1100 Subject: [PATCH 07/25] Test that an unlimited number of lines is not read by __init__ --- Tests/test_file_gimppalette.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index c3d2bfeb7..08862113b 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -41,10 +41,17 @@ def test_get_palette(filename: str, size: int) -> None: def test_frombytes() -> None: - with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: - full_data = fp.read() + # Test that __init__ stops reading after 260 lines + with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: + custom_data = fp.read() + custom_data += b"#\n" * 300 + b" 0 0 0 Index 12" + b = BytesIO(custom_data) + palette = GimpPaletteFile(b) + assert len(palette.palette) / 3 == 8 # Test that __init__ only reads 256 entries + with open("Tests/images/full_gimp_palette.gpl", "rb") as fp: + full_data = fp.read() data = full_data.replace(b"#\n", b"") + b" 0 0 0 Index 256" b = BytesIO(data) palette = GimpPaletteFile(b) From bca693bd82ce1dab40a375d101c5292e3a275143 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:33:45 +1100 Subject: [PATCH 08/25] Updated harfbuzz to 11.0.0 (#8830) Co-authored-by: Andrew Murray --- .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 e9c54536e..51ba0cf28 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -38,7 +38,7 @@ ARCHIVE_SDIR=pillow-depends-main # Package versions for fresh source builds FREETYPE_VERSION=2.13.3 -HARFBUZZ_VERSION=10.4.0 +HARFBUZZ_VERSION=11.0.0 LIBPNG_VERSION=1.6.47 JPEGTURBO_VERSION=3.1.0 OPENJPEG_VERSION=2.5.3 diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index ea3d99394..2e9e18719 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -113,7 +113,7 @@ V = { "BROTLI": "1.1.0", "FREETYPE": "2.13.3", "FRIBIDI": "1.0.16", - "HARFBUZZ": "10.4.0", + "HARFBUZZ": "11.0.0", "JPEGTURBO": "3.1.0", "LCMS2": "2.17", "LIBIMAGEQUANT": "4.3.4", From 053b5790e13030b9ac13a70489f48453dab7cb8f Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:22:21 +1100 Subject: [PATCH 09/25] Added media_white_point (#8829) Co-authored-by: Andrew Murray --- docs/reference/ImageCms.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst index 96bd14dd3..4a1f5a3ee 100644 --- a/docs/reference/ImageCms.rst +++ b/docs/reference/ImageCms.rst @@ -286,6 +286,14 @@ can be easily displayed in a chromaticity diagram, for example). The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point + :type: tuple[tuple[float, float, float], tuple[float, float, float]] | None + + This tag specifies the media white point and is used for + generating absolute colorimetry. + + The value is in the format ``((X, Y, Z), (x, y, Y))``, if available. + .. py:attribute:: media_white_point_temperature :type: float | None From 14d495a519a4c444c3f3ef369c21ad45551f689b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:41:03 +0000 Subject: [PATCH 10/25] Update dependency cibuildwheel to v2.23.2 --- .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 f2109ed61..db5f89c9a 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.23.1 +cibuildwheel==2.23.2 From b8abded99b4f77db591e270836156349a074c3bc Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Wed, 26 Mar 2025 00:31:49 +1100 Subject: [PATCH 11/25] Change back to actions/setup-python (#8833) Co-authored-by: Andrew Murray --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4ad88be9..006d574f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: Quansight-Labs/setup-python@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 295a5e9bd7fe021df6ea3aa41d54738dcd3f8250 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 7 Jan 2025 19:23:04 +1100 Subject: [PATCH 12/25] Do not convert BC1 LUT to UINT32 --- src/libImaging/BcnDecode.c | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index 9a41febc7..7b3d8f908 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -25,7 +25,6 @@ typedef struct { typedef struct { UINT16 c0, c1; - UINT32 lut; } bc1_color; typedef struct { @@ -40,13 +39,10 @@ typedef struct { #define LOAD16(p) (p)[0] | ((p)[1] << 8) -#define LOAD32(p) (p)[0] | ((p)[1] << 8) | ((p)[2] << 16) | ((p)[3] << 24) - static void bc1_color_load(bc1_color *dst, const UINT8 *src) { dst->c0 = LOAD16(src); dst->c1 = LOAD16(src + 2); - dst->lut = LOAD32(src + 4); } static rgba @@ -70,7 +66,7 @@ static void decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { bc1_color col; rgba p[4]; - int n, cw; + int n, o, cw; UINT16 r0, g0, b0, r1, g1, b1; bc1_color_load(&col, src); @@ -103,9 +99,11 @@ decode_bc1_color(rgba *dst, const UINT8 *src, int separate_alpha) { p[3].b = 0; p[3].a = 0; } - for (n = 0; n < 16; n++) { - cw = 3 & (col.lut >> (2 * n)); - dst[n] = p[cw]; + for (n = 0; n < 4; n++) { + for (o = 0; o < 4; o++) { + cw = 3 & ((src + 4)[n] >> (2 * o)); + dst[n * 4 + o] = p[cw]; + } } } From e1f0def839776c131a8f297c7b05712d1cfbee75 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:43:07 +1100 Subject: [PATCH 13/25] Updated xz to 5.8.0, except on manylinux2014 (#8836) Co-authored-by: Andrew Murray --- .github/workflows/wheels-dependencies.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 51ba0cf28..461729a74 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -42,7 +42,11 @@ HARFBUZZ_VERSION=11.0.0 LIBPNG_VERSION=1.6.47 JPEGTURBO_VERSION=3.1.0 OPENJPEG_VERSION=2.5.3 -XZ_VERSION=5.6.4 +if [[ $MB_ML_VER == 2014 ]]; then + XZ_VERSION=5.6.4 +else + XZ_VERSION=5.8.0 +fi TIFF_VERSION=4.7.0 LCMS2_VERSION=2.17 ZLIB_NG_VERSION=2.2.4 From 3c185d1f696dfbe20a5401a2f36edd392b2094d2 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:44:27 +1100 Subject: [PATCH 14/25] Do not load image during save if file extension is unknown (#8835) Co-authored-by: Andrew Murray --- Tests/test_file_hdf5stub.py | 2 +- src/PIL/Image.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 50864009f..e4f09a09c 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -43,7 +43,7 @@ def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: dummy_fp = BytesIO() - dummy_filename = "dummy.filename" + dummy_filename = "dummy.h5" # Act / Assert: stub cannot save without an implemented handler with pytest.raises(OSError): diff --git a/src/PIL/Image.py b/src/PIL/Image.py index e2a76dfe1..d338e4dfd 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2510,13 +2510,6 @@ class Image: # only set the name for metadata purposes filename = os.fspath(fp.name) - # may mutate self! - self._ensure_mutable() - - save_all = params.pop("save_all", False) - self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} - self.encoderconfig: tuple[Any, ...] = () - preinit() filename_ext = os.path.splitext(filename)[1].lower() @@ -2531,6 +2524,13 @@ class Image: msg = f"unknown file extension: {ext}" raise ValueError(msg) from e + # may mutate self! + self._ensure_mutable() + + save_all = params.pop("save_all", False) + self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} + self.encoderconfig: tuple[Any, ...] = () + if format.upper() not in SAVE: init() if save_all: From 10ccbd7788532f72939d38c41f3f7303ff07b4da Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 28 Mar 2025 03:01:09 +1100 Subject: [PATCH 15/25] If append_images is populated, default save_all to True (#8781) Co-authored-by: Andrew Murray --- Tests/test_file_gif.py | 6 ++++++ docs/handbook/image-file-formats.rst | 26 ++++++++++++++------------ docs/handbook/tutorial.rst | 1 - src/PIL/Image.py | 8 ++++++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index ba0963e8c..376eab0c6 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1138,6 +1138,12 @@ def test_append_images(tmp_path: Path) -> None: ims = [Image.new("RGB", (100, 100), color) for color in ["#0f0", "#00f"]] im.copy().save(out, save_all=True, append_images=ims) + with Image.open(out) as reread: + assert reread.n_frames == 3 + + # Test append_images without save_all + im.copy().save(out, append_images=ims) + with Image.open(out) as reread: assert reread.n_frames == 3 diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 97599ace5..c0b1a9d4e 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -235,8 +235,9 @@ following options are available:: im.save(out, save_all=True, append_images=[im1, im2, ...]) **save_all** - If present and true, all frames of the image will be saved. If - not, then only the first frame of a multiframe image will be saved. + If present and true, or if ``append_images`` is not empty, all frames of + the image will be saved. Otherwise, only the first frame of a multiframe + image will be saved. **append_images** A list of images to append as additional frames. Each of the @@ -723,8 +724,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save` to write an MPO file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -option will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved. **append_images** A list of images to append as additional pictures. Each of the @@ -934,7 +935,8 @@ Saving When calling :py:meth:`~PIL.Image.Image.save`, by default only a single frame PNG file will be saved. To save an APNG file (including a single frame APNG), the ``save_all`` -parameter must be set to ``True``. The following parameters can also be set: +parameter should be set to ``True`` or ``append_images`` should not be empty. The +following parameters can also be set: **default_image** Boolean value, specifying whether or not the base image is a default image. @@ -1163,7 +1165,8 @@ Saving The :py:meth:`~PIL.Image.Image.save` method can take the following keyword arguments: **save_all** - If true, Pillow will save all frames of the image to a multiframe tiff document. + If true, or if ``append_images`` is not empty, Pillow will save all frames of the + image to a multiframe tiff document. .. versionadded:: 3.4.0 @@ -1313,8 +1316,8 @@ Saving sequences When calling :py:meth:`~PIL.Image.Image.save` to write a WebP file, by default only the first frame of a multiframe image will be saved. If the ``save_all`` -argument is present and true, then all frames will be saved, and the following -options will also be available. +argument is present and true, or if ``append_images`` is not empty, all frames +will be saved, and the following options will also be available. **append_images** A list of images to append as additional frames. Each of the @@ -1616,15 +1619,14 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum **save_all** If a multiframe image is used, by default, only the first image will be saved. To save all frames, each frame to a separate page of the PDF, the ``save_all`` - parameter must be present and set to ``True``. + parameter should be present and set to ``True`` or ``append_images`` should not be + empty. .. versionadded:: 3.0.0 **append_images** A list of :py:class:`PIL.Image.Image` objects to append as additional pages. Each - of the images in the list can be single or multiframe images. The ``save_all`` - parameter must be present and set to ``True`` in conjunction with - ``append_images``. + of the images in the list can be single or multiframe images. .. versionadded:: 4.2.0 diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index f771ae7ae..f1a2849b8 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -534,7 +534,6 @@ You can create animated GIFs with Pillow, e.g. # Save the images as an animated GIF images[0].save( "animated_hopper.gif", - save_all=True, append_images=images[1:], duration=500, # duration of each frame in milliseconds loop=0, # loop forever diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d338e4dfd..67e068b06 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -2527,13 +2527,17 @@ class Image: # may mutate self! self._ensure_mutable() - save_all = params.pop("save_all", False) + save_all = params.pop("save_all", None) self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params} self.encoderconfig: tuple[Any, ...] = () if format.upper() not in SAVE: init() - if save_all: + if save_all or ( + save_all is None + and params.get("append_images") + and format.upper() in SAVE_ALL + ): save_handler = SAVE_ALL[format.upper()] else: save_handler = SAVE[format.upper()] From 1cb6c7c347a7509e0303c32cfe8f5d1e063be1b3 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:27:39 +1100 Subject: [PATCH 16/25] Parametrize tests (#8838) Co-authored-by: Andrew Murray --- Tests/test_file_bmp.py | 28 ++++++++------------ Tests/test_file_sgi.py | 28 +++++++++++--------- Tests/test_file_tga.py | 60 ++++++++++++++++++++++-------------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 64d2acaf5..8a94011e8 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -15,25 +15,19 @@ from .helper import ( ) -def test_sanity(tmp_path: Path) -> None: - def roundtrip(im: Image.Image) -> None: - outfile = tmp_path / "temp.bmp" +@pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) +def test_sanity(mode: str, tmp_path: Path) -> None: + outfile = tmp_path / "temp.bmp" - im.save(outfile, "BMP") + im = hopper(mode) + im.save(outfile, "BMP") - with Image.open(outfile) as reloaded: - reloaded.load() - assert im.mode == reloaded.mode - assert im.size == reloaded.size - assert reloaded.format == "BMP" - assert reloaded.get_format_mimetype() == "image/bmp" - - roundtrip(hopper()) - - roundtrip(hopper("1")) - roundtrip(hopper("L")) - roundtrip(hopper("P")) - roundtrip(hopper("RGB")) + with Image.open(outfile) as reloaded: + reloaded.load() + assert im.mode == reloaded.mode + assert im.size == reloaded.size + assert reloaded.format == "BMP" + assert reloaded.get_format_mimetype() == "image/bmp" def test_invalid_file() -> None: diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 7d34fa4b5..da0965fa1 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -71,24 +71,26 @@ def test_invalid_file() -> None: SgiImagePlugin.SgiImageFile(invalid_file) -def test_write(tmp_path: Path) -> None: - def roundtrip(img: Image.Image) -> None: - out = tmp_path / "temp.sgi" - img.save(out, format="sgi") +def roundtrip(img: Image.Image, tmp_path: Path) -> None: + out = tmp_path / "temp.sgi" + img.save(out, format="sgi") + assert_image_equal_tofile(img, out) + + out = tmp_path / "fp.sgi" + with open(out, "wb") as fp: + img.save(fp) assert_image_equal_tofile(img, out) - out = tmp_path / "fp.sgi" - with open(out, "wb") as fp: - img.save(fp) - assert_image_equal_tofile(img, out) + assert not fp.closed - assert not fp.closed - for mode in ("L", "RGB", "RGBA"): - roundtrip(hopper(mode)) +@pytest.mark.parametrize("mode", ("L", "RGB", "RGBA")) +def test_write(mode: str, tmp_path: Path) -> None: + roundtrip(hopper(mode), tmp_path) - # Test 1 dimension for an L mode image - roundtrip(Image.new("L", (10, 1))) + +def test_write_L_mode_1_dimension(tmp_path: Path) -> None: + roundtrip(Image.new("L", (10, 1)), tmp_path) def test_write16(tmp_path: Path) -> None: diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index a10d4d7ab..8b6ed3ed2 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,8 +1,6 @@ from __future__ import annotations import os -from glob import glob -from itertools import product from pathlib import Path import pytest @@ -15,14 +13,27 @@ _TGA_DIR = os.path.join("Tests", "images", "tga") _TGA_DIR_COMMON = os.path.join(_TGA_DIR, "common") -_MODES = ("L", "LA", "P", "RGB", "RGBA") _ORIGINS = ("tl", "bl") _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1} -@pytest.mark.parametrize("mode", _MODES) -def test_sanity(mode: str, tmp_path: Path) -> None: +@pytest.mark.parametrize( + "size_mode", + ( + ("1x1", "L"), + ("200x32", "L"), + ("200x32", "LA"), + ("200x32", "P"), + ("200x32", "RGB"), + ("200x32", "RGBA"), + ), +) +@pytest.mark.parametrize("origin", _ORIGINS) +@pytest.mark.parametrize("rle", (True, False)) +def test_sanity( + size_mode: tuple[str, str], origin: str, rle: str, tmp_path: Path +) -> None: def roundtrip(original_im: Image.Image) -> None: out = tmp_path / "temp.tga" @@ -36,33 +47,26 @@ def test_sanity(mode: str, tmp_path: Path) -> None: assert_image_equal(saved_im, original_im) - png_paths = glob(os.path.join(_TGA_DIR_COMMON, f"*x*_{mode.lower()}.png")) + size, mode = size_mode + png_path = os.path.join(_TGA_DIR_COMMON, size + "_" + mode.lower() + ".png") + with Image.open(png_path) as reference_im: + assert reference_im.mode == mode - for png_path in png_paths: - with Image.open(png_path) as reference_im: - assert reference_im.mode == mode + path_no_ext = os.path.splitext(png_path)[0] + tga_path = "{}_{}_{}.tga".format(path_no_ext, origin, "rle" if rle else "raw") - path_no_ext = os.path.splitext(png_path)[0] - for origin, rle in product(_ORIGINS, (True, False)): - tga_path = "{}_{}_{}.tga".format( - path_no_ext, origin, "rle" if rle else "raw" - ) + with Image.open(tga_path) as original_im: + assert original_im.format == "TGA" + assert original_im.get_format_mimetype() == "image/x-tga" + if rle: + assert original_im.info["compression"] == "tga_rle" + assert original_im.info["orientation"] == _ORIGIN_TO_ORIENTATION[origin] + if mode == "P": + assert original_im.getpalette() == reference_im.getpalette() - with Image.open(tga_path) as original_im: - assert original_im.format == "TGA" - assert original_im.get_format_mimetype() == "image/x-tga" - if rle: - assert original_im.info["compression"] == "tga_rle" - assert ( - original_im.info["orientation"] - == _ORIGIN_TO_ORIENTATION[origin] - ) - if mode == "P": - assert original_im.getpalette() == reference_im.getpalette() + assert_image_equal(original_im, reference_im) - assert_image_equal(original_im, reference_im) - - roundtrip(original_im) + roundtrip(original_im) def test_palette_depth_8() -> None: From ae52f9f37d2bee5ccd2ce10fb9c3d6318e675b89 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 00:21:51 +1100 Subject: [PATCH 17/25] Added release notes for #8781 and #8837 (#8843) Co-authored-by: Andrew Murray --- docs/releasenotes/11.2.0.rst | 37 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/docs/releasenotes/11.2.0.rst b/docs/releasenotes/11.2.0.rst index 3e977221e..d40d86f21 100644 --- a/docs/releasenotes/11.2.0.rst +++ b/docs/releasenotes/11.2.0.rst @@ -4,21 +4,12 @@ Security ======== -TODO -^^^^ +Undefined shift when loading compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO - -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - -Backwards Incompatible Changes -============================== - -TODO -^^^^ +When loading some compressed DDS formats, an integer was bitshifted by 24 places to +generate the 32 bits of the lookup table. This was undefined behaviour, and has been +present since Pillow 3.4.0. Deprecations ============ @@ -36,10 +27,14 @@ an :py:class:`PIL.ImageFile.ImageFile` instance. API Changes =========== -TODO -^^^^ +``append_images`` no longer requires ``save_all`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +Previously, ``save_all`` was required to in order to use ``append_images``. Now, +``save_all`` will default to ``True`` if ``append_images`` is not empty and the format +supports saving multiple frames:: + + im.save("out.gif", append_images=ims) API Additions ============= @@ -73,11 +68,3 @@ Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT5, BC2, BC3 and BC5 are supported:: im.save("out.dds", pixel_format="DXT1") - -Other Changes -============= - -TODO -^^^^ - -TODO From 6d42449788e4c05a76cc7c9c81a7c8b2a40d099e Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:25:13 +1100 Subject: [PATCH 18/25] Allow loading of EMF images at a given DPI (#8536) Co-authored-by: Andrew Murray --- Tests/images/drawing_emf_ref_72_144.png | Bin 0 -> 984 bytes Tests/test_file_wmf.py | 14 ++++++++++++++ src/PIL/WmfImagePlugin.py | 24 ++++++++++++++---------- 3 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 Tests/images/drawing_emf_ref_72_144.png diff --git a/Tests/images/drawing_emf_ref_72_144.png b/Tests/images/drawing_emf_ref_72_144.png new file mode 100644 index 0000000000000000000000000000000000000000..000377b6c7b1e05688fc17d5da09d163a18c5b4b GIT binary patch literal 984 zcmV;}11J26P) z+m?eM3`K+g|If}tT8An^fFxX!{W??4_QVUcOTu}cFoF=ms9ghq-o8T!`G3$n3L4r; z;S(Tv7 z*g4bg5BZ5u>}=ZTECjnbkG7~Y!fVc;t>BC>n)hm}IU`)=UE0dd2#a~U_7G>J-@H+K zpfl2G-l#p+8R@B^MO*Hfv6kjaTC`_~8fk9zYVCQVM%pr{(;j{$OVW@;o%V#z&{S20 z_H6APQ(GHVd(QU0sJ*sPwP$ulswyOD&)nWI^g2n}^GA))>lB$n90)P+vi2$+jt~Pc zYp>GbRTQ+>iW;HRT+m)IYD&#H?G>X&ik0WISBx4dR=(Q}jL56@x*d+>>wnc=x5JTq z{odpet9ST^cZ;<4>K$IhoBYcr9ge)XB(%5haPTI##a(-=6B|hx-L);8*x*fWiy!R` zPi*Aj^`mX%#D-XV+o%EHVdv+zC0yGQuDvz4pF4cCC;yEGJ66)Z;o6pP?cIql<_Flj zjDxAPV_e%3u5Ag|wuEb2!nG~o+Lo}TeT_YBX8{;W*E8=kK*&u$uPh z0#pg#mvPzBzHn_zxV9x++Y+vA3D>rSYg^&~0E0trLup^5PB5h%oqCm9%f69=AWL)}qDpk;FvW&-2%W_7m4ewmZF(V~zdOPTrXJ z*G{sz_SAfY3qS45;(n1@>+kbrKnL=A$hI8>3{A})shuAu$f!EHj>I!TPwG& zL#U6Wa@E!;18=CRere*`4+zs%Pqp@J*S59>Y+8SNnpSTPpm8WNL*NZpvWrIT;jP}| z3_SzSf##jg&^g{7V&3lz{nHG}<}A*@GP|N?&gBeTlS(?~j5kiUxinGpzU3_C`9IR4e2CH??qha)B=@Nu*N0000 None: assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref_144.png", 2.1) + with Image.open("Tests/images/drawing.emf") as im: + assert im.size == (1625, 1625) + + if not hasattr(Image.core, "drawwmf"): + return + im.load(im.info["dpi"]) + assert im.size == (1625, 1625) + + with Image.open("Tests/images/drawing.emf") as im: + im.load((72, 144)) + assert im.size == (82, 164) + + assert_image_equal_tofile(im, "Tests/images/drawing_emf_ref_72_144.png") + @pytest.mark.parametrize("ext", (".wmf", ".emf")) def test_save(ext: str, tmp_path: Path) -> None: diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index 04abd52f0..f709d026b 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -80,8 +80,6 @@ class WmfStubImageFile(ImageFile.StubImageFile): format_description = "Windows Metafile" def _open(self) -> None: - self._inch = None - # check placable header s = self.fp.read(80) @@ -89,10 +87,11 @@ class WmfStubImageFile(ImageFile.StubImageFile): # placeable windows metafile # get units per inch - self._inch = word(s, 14) - if self._inch == 0: + inch = word(s, 14) + if inch == 0: msg = "Invalid inch" raise ValueError(msg) + self._inch: tuple[float, float] = inch, inch # get bounding box x0 = short(s, 6) @@ -103,8 +102,8 @@ class WmfStubImageFile(ImageFile.StubImageFile): # normalize size to 72 dots per inch self.info["dpi"] = 72 size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + (x1 - x0) * self.info["dpi"] // inch, + (y1 - y0) * self.info["dpi"] // inch, ) self.info["wmf_bbox"] = x0, y0, x1, y1 @@ -138,6 +137,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): self.info["dpi"] = xdpi else: self.info["dpi"] = xdpi, ydpi + self._inch = xdpi, ydpi else: msg = "Unsupported file format" @@ -153,13 +153,17 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _load(self) -> ImageFile.StubHandler | None: return _handler - def load(self, dpi: int | None = None) -> Image.core.PixelAccess | None: - if dpi is not None and self._inch is not None: + def load( + self, dpi: float | tuple[float, float] | None = None + ) -> Image.core.PixelAccess | None: + if dpi is not None: self.info["dpi"] = dpi x0, y0, x1, y1 = self.info["wmf_bbox"] + if not isinstance(dpi, tuple): + dpi = dpi, dpi self._size = ( - (x1 - x0) * self.info["dpi"] // self._inch, - (y1 - y0) * self.info["dpi"] // self._inch, + int((x1 - x0) * dpi[0] / self._inch[0]), + int((y1 - y0) * dpi[1] / self._inch[1]), ) return super().load() From 93cdfeb48879064314af70faf98726e09fb06ab0 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:25:57 +1100 Subject: [PATCH 19/25] Prevent TIFFRGBAImageBegin from applying image orientation (#8556) Co-authored-by: Andrew Murray --- Tests/test_file_libtiff.py | 11 +++++++++++ src/libImaging/TiffDecode.c | 1 + 2 files changed, 12 insertions(+) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 25d1f5712..9e63e9c10 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1026,6 +1026,17 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open("Tests/images/old-style-jpeg-compression.tif") as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_old_style_jpeg_orientation(self) -> None: + with open("Tests/images/old-style-jpeg-compression.tif", "rb") as fp: + data = fp.read() + + # Set EXIF Orientation to 2 + data = data[:102] + b"\x02" + data[103:] + + with Image.open(io.BytesIO(data)) as im: + im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + def test_open_missing_samplesperpixel(self) -> None: with Image.open( "Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif" diff --git a/src/libImaging/TiffDecode.c b/src/libImaging/TiffDecode.c index e4da9162d..9a2db95b4 100644 --- a/src/libImaging/TiffDecode.c +++ b/src/libImaging/TiffDecode.c @@ -299,6 +299,7 @@ _decodeAsRGBA(Imaging im, ImagingCodecState state, TIFF *tiff) { return -1; } + img.orientation = ORIENTATION_TOPLEFT; img.req_orientation = ORIENTATION_TOPLEFT; img.col_offset = 0; From 140e426082bfe1ec84c5d6eb34a3c47eb1d89185 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:27:00 +1100 Subject: [PATCH 20/25] Added USE_RAW_ALPHA (#8602) Co-authored-by: Andrew Murray --- Tests/test_file_bmp.py | 10 ++++++++++ src/PIL/BmpImagePlugin.py | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 8a94011e8..757650711 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -224,3 +224,13 @@ def test_offset() -> None: # to exclude the palette size from the pixel data offset with Image.open("Tests/images/pal8_offset.bmp") as im: assert_image_equal_tofile(im, "Tests/images/bmp/g/pal8.bmp") + + +def test_use_raw_alpha(monkeypatch: pytest.MonkeyPatch) -> None: + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.info["compression"] == BmpImagePlugin.BmpImageFile.COMPRESSIONS["RAW"] + assert im.mode == "RGB" + + monkeypatch.setattr(BmpImagePlugin, "USE_RAW_ALPHA", True) + with Image.open("Tests/images/bmp/g/rgb32.bmp") as im: + assert im.mode == "RGBA" diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index d60ea591a..43131cfe2 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -48,6 +48,8 @@ BIT2MODE = { 32: ("RGB", "BGRX"), } +USE_RAW_ALPHA = False + def _accept(prefix: bytes) -> bool: return prefix.startswith(b"BM") @@ -242,7 +244,9 @@ class BmpImageFile(ImageFile.ImageFile): msg = "Unsupported BMP bitfields layout" raise OSError(msg) elif file_info["compression"] == self.COMPRESSIONS["RAW"]: - if file_info["bits"] == 32 and header == 22: # 32-bit .cur offset + if file_info["bits"] == 32 and ( + header == 22 or USE_RAW_ALPHA # 32-bit .cur offset + ): raw_mode, self._mode = "BGRA", "RGBA" elif file_info["compression"] in ( self.COMPRESSIONS["RLE8"], From 6bffa3a9d4fe3d5a50c505ec0637d70cc1e8d59b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:29:02 +1100 Subject: [PATCH 21/25] Only read until the offset of the next tile (#8609) Co-authored-by: Andrew Murray --- Tests/test_imagefile.py | 20 ++++++++++++++++++++ src/PIL/ImageFile.py | 9 +++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index c60a475a3..7622eea99 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -131,6 +131,26 @@ class TestImageFile: assert_image_equal(im1, im2) + def test_tile_size(self) -> None: + with open("Tests/images/hopper.tif", "rb") as im_fp: + data = im_fp.read() + + reads = [] + + class FP(BytesIO): + def read(self, size: int | None = None) -> bytes: + reads.append(size) + return super().read(size) + + fp = FP(data) + with Image.open(fp) as im: + assert len(im.tile) == 7 + + im.load() + + # Despite multiple tiles, assert only one tile caused a read of maxblock size + assert reads.count(im.decodermaxblock) == 1 + def test_raise_oserror(self) -> None: with pytest.warns(DeprecationWarning): with pytest.raises(OSError): diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 1bf8a7e5f..9470a8dd7 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -345,7 +345,7 @@ class ImageFile(Image.Image): self.tile, lambda tile: (tile[0], tile[1], tile[3]) ) ] - for decoder_name, extents, offset, args in self.tile: + for i, (decoder_name, extents, offset, args) in enumerate(self.tile): seek(offset) decoder = Image._getdecoder( self.mode, decoder_name, args, self.decoderconfig @@ -358,8 +358,13 @@ class ImageFile(Image.Image): else: b = prefix while True: + read_bytes = self.decodermaxblock + if i + 1 < len(self.tile): + next_offset = self.tile[i + 1].offset + if next_offset > offset: + read_bytes = next_offset - offset try: - s = read(self.decodermaxblock) + s = read(read_bytes) except (IndexError, struct.error) as e: # truncated png/gif if LOAD_TRUNCATED_IMAGES: From 03dc994baaa98385ef313669dbfd6e5e80f86380 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:30:30 +1100 Subject: [PATCH 22/25] Check that _fp type is not DeferredError before use (#8640) --- src/PIL/DcxImagePlugin.py | 3 +++ src/PIL/FliImagePlugin.py | 3 +++ src/PIL/GifImagePlugin.py | 3 +++ src/PIL/ImImagePlugin.py | 3 +++ src/PIL/ImageFile.py | 2 +- src/PIL/MpoImagePlugin.py | 5 +++++ src/PIL/PngImagePlugin.py | 3 +++ src/PIL/PsdImagePlugin.py | 5 +++++ src/PIL/SpiderImagePlugin.py | 3 +++ src/PIL/TiffImagePlugin.py | 4 +++- 10 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index f67f27d73..aea661b9c 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -24,6 +24,7 @@ from __future__ import annotations from . import Image from ._binary import i32le as i32 +from ._util import DeferredError from .PcxImagePlugin import PcxImageFile MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then? @@ -66,6 +67,8 @@ class DcxImageFile(PcxImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame self.fp = self._fp self.fp.seek(self._offset[frame]) diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index b534b30ab..7c5bfeefa 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -22,6 +22,7 @@ from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 from ._binary import i32le as i32 from ._binary import o8 +from ._util import DeferredError # # decoder @@ -134,6 +135,8 @@ class FliImageFile(ImageFile.ImageFile): self._seek(f) def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: self.__frame = -1 self._fp.seek(self.__rewind) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 259e93f09..045ab1027 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -45,6 +45,7 @@ from . import ( from ._binary import i16le as i16 from ._binary import o8 from ._binary import o16le as o16 +from ._util import DeferredError if TYPE_CHECKING: from . import _imaging @@ -167,6 +168,8 @@ class GifImageFile(ImageFile.ImageFile): raise EOFError(msg) from e def _seek(self, frame: int, update_image: bool = True) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex if frame == 0: # rewind self.__offset = 0 diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 9f20b30f8..71b999678 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -31,6 +31,7 @@ import re from typing import IO, Any from . import Image, ImageFile, ImagePalette +from ._util import DeferredError # -------------------------------------------------------------------- # Standard tags @@ -290,6 +291,8 @@ class ImImageFile(ImageFile.ImageFile): def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.frame = frame diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 9470a8dd7..f5b72ee0d 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -167,7 +167,7 @@ class ImageFile(Image.Image): pass def _close_fp(self): - if getattr(self, "_fp", False): + if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): if self._fp != self.fp: self._fp.close() self._fp = DeferredError(ValueError("Operation on closed image")) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index e08f80b6b..f7393eac0 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -32,6 +32,7 @@ from . import ( TiffImagePlugin, ) from ._binary import o32le +from ._util import DeferredError def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: @@ -125,11 +126,15 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): self.readonly = 1 def load_seek(self, pos: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(pos) def seek(self, frame: int) -> None: if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp self.offset = self.__mpoffsets[frame] diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 4fc6217e1..3e3cf6526 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -48,6 +48,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._binary import o16be as o16 from ._binary import o32be as o32 +from ._util import DeferredError if TYPE_CHECKING: from . import _imaging @@ -869,6 +870,8 @@ class PngImageFile(ImageFile.ImageFile): def _seek(self, frame: int, rewind: bool = False) -> None: assert self.png is not None + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.dispose: _imaging.ImagingCore | None dispose_extent = None diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 0aada8a06..f49aaeeb1 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -27,6 +27,7 @@ from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 from ._binary import si32be as si32 +from ._util import DeferredError MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -148,6 +149,8 @@ class PsdImageFile(ImageFile.ImageFile): ) -> list[tuple[str, str, tuple[int, int, int, int], list[ImageFile._Tile]]]: layers = [] if self._layers_position is not None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self._fp.seek(self._layers_position) _layer_data = io.BytesIO(ImageFile._safe_read(self._fp, self._layers_size)) layers = _layerinfo(_layer_data, self._layers_size) @@ -167,6 +170,8 @@ class PsdImageFile(ImageFile.ImageFile): def seek(self, layer: int) -> None: if not self._seek_check(layer): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex # seek to given layer (1..max) _, mode, _, tile = self.layers[layer - 1] diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index b26e1a996..62fa7be03 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -40,6 +40,7 @@ import sys from typing import IO, TYPE_CHECKING, Any, cast from . import Image, ImageFile +from ._util import DeferredError def isInt(f: Any) -> int: @@ -178,6 +179,8 @@ class SpiderImageFile(ImageFile.ImageFile): raise EOFError(msg) if not self._seek_check(frame): return + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.stkoffset = self.hdrlen + frame * (self.hdrlen + self.imgbytes) self.fp = self._fp self.fp.seek(self.stkoffset) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 39783f1f8..ebe599cca 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -58,7 +58,7 @@ from ._binary import i32be as i32 from ._binary import o8 from ._deprecate import deprecate from ._typing import StrOrBytesPath -from ._util import is_path +from ._util import DeferredError, is_path from .TiffTags import TYPES if TYPE_CHECKING: @@ -1222,6 +1222,8 @@ class TiffImageFile(ImageFile.ImageFile): self._im = None def _seek(self, frame: int) -> None: + if isinstance(self._fp, DeferredError): + raise self._fp.ex self.fp = self._fp while len(self._frame_pos) <= frame: From e8a9b566036a041f7643d32bf9050ee31de8163c Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:33:51 +1100 Subject: [PATCH 23/25] Improved connecting discontiguous corners (#8659) --- .../discontiguous_corners_polygon.png | Bin 533 -> 533 bytes Tests/test_imagedraw.py | 2 +- src/libImaging/Draw.c | 58 ++++++++---------- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/Tests/images/imagedraw/discontiguous_corners_polygon.png b/Tests/images/imagedraw/discontiguous_corners_polygon.png index 1b58889c8f3ae45243a7509c907f1928534bcbde..8992a165758676b9ba7777347fb09a3b8c453e3d 100644 GIT binary patch delta 498 zcmV!!QU#x&NVZwfxr}B*1oAc++Wu^hDyMmFv3h`#y7( z^bX*wh={0kNqVm~ZJk<@{7}`frq38OUF0%$Ri9_ST)mw}n@*lB8`kG(v?=7-vMC9z zPn)FNOFlq0L$jBBfNY*-7kRd9wq_%Fw(Ja(TpUFHEepqypH5182bmeUaqnc-s_XgQ{+#-eC+wU`t#9JT*W=9jlj!u-K3$uKcEi$mwNKY+qig+H7V^#a7ga8(`}`_7Z^}zP zQ9q<-sx8egsg|Yw#I!ktma!{-pYw9{cA9Otc{FxdpQqV|m`7tnCY0Yc zh}>&F0UN>WHJ^ZuV|JNGW22dk=F!+W%xd#!?5C`Y&7-j;iGLTEM`LSqPco0j_UtvF zc|TaBnTP~M<};~0<^#KGm-vZS{wjg@Oab*XJUP5=3P_!s&8re=9eQD8B{9Jc+$-AK5ydi~mnpf?M*P557 z@N)B#eer(t%M?yAKiL-tnSV;*IP>a^$~!1re}Az#u=?k@{eksw9&mD^TA$$8<{7%T zU(d|ucb|eKly1+L^El`HZd84>FK6@E9$Xr)_T`*5+iTxtF+aS1Q}Pz|J-^GG59Kvq z$RE;k|Gt3?V-1fyE-1*Ls!QC1{o@oTsl+9J2Pa? o!@AA+ej3>3+2wVTOis1_06el#wiqRwUjP6A07*qoM6N<$f>{3oE&u=k diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 2767418ea..ffe9c0979 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1704,7 +1704,7 @@ def test_discontiguous_corners_polygon() -> None: BLACK, ) expected = os.path.join(IMAGES_PATH, "discontiguous_corners_polygon.png") - assert_image_similar_tofile(img, expected, 1) + assert_image_equal_tofile(img, expected) def test_polygon2() -> None: diff --git a/src/libImaging/Draw.c b/src/libImaging/Draw.c index ea6f8805e..d5aff8709 100644 --- a/src/libImaging/Draw.c +++ b/src/libImaging/Draw.c @@ -501,55 +501,49 @@ polygon_generic( // Needed to draw consistent polygons xx[j] = xx[j - 1]; j++; - } else if (current->dx != 0 && j % 2 == 1 && - roundf(xx[j - 1]) == xx[j - 1]) { + } else if ((ymin == current->ymin || ymin == current->ymax) && + current->dx != 0) { // Connect discontiguous corners for (k = 0; k < i; k++) { Edge *other_edge = edge_table[k]; - if ((current->dx > 0 && other_edge->dx <= 0) || - (current->dx < 0 && other_edge->dx >= 0)) { + if ((ymin != other_edge->ymin && ymin != other_edge->ymax) || + other_edge->dx == 0) { continue; } // Check if the two edges join to make a corner - if (xx[j - 1] == - (ymin - other_edge->y0) * other_edge->dx + other_edge->x0) { + if (roundf(xx[j - 1]) == + roundf( + (ymin - other_edge->y0) * other_edge->dx + + other_edge->x0 + )) { // Determine points from the edges on the next row // Or if this is the last row, check the previous row - int offset = ymin == ymax ? -1 : 1; + int offset = ymin == current->ymax ? -1 : 1; adjacent_line_x = (ymin + offset - current->y0) * current->dx + current->x0; - adjacent_line_x_other_edge = - (ymin + offset - other_edge->y0) * other_edge->dx + - other_edge->x0; - if (ymin == current->ymax) { - if (current->dx > 0) { - xx[k] = - fmax( + if (ymin + offset >= other_edge->ymin && + ymin + offset <= other_edge->ymax) { + adjacent_line_x_other_edge = + (ymin + offset - other_edge->y0) * other_edge->dx + + other_edge->x0; + if (xx[j - 1] > adjacent_line_x + 1 && + xx[j - 1] > adjacent_line_x_other_edge + 1) { + xx[j - 1] = + roundf(fmax( adjacent_line_x, adjacent_line_x_other_edge - ) + + )) + 1; - } else { - xx[k] = - fmin( + } else if (xx[j - 1] < adjacent_line_x - 1 && + xx[j - 1] < adjacent_line_x_other_edge - 1) { + xx[j - 1] = + roundf(fmin( adjacent_line_x, adjacent_line_x_other_edge - ) - - 1; - } - } else { - if (current->dx > 0) { - xx[k] = fmin( - adjacent_line_x, adjacent_line_x_other_edge - ); - } else { - xx[k] = - fmax( - adjacent_line_x, adjacent_line_x_other_edge - ) + + )) - 1; } + break; } - break; } } } From 25653d2f87f0f0be370442836b472a43c1898b71 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:34:42 +1100 Subject: [PATCH 24/25] Corrected P mode save (#8685) --- Tests/test_file_palm.py | 6 +++++- src/PIL/PalmImagePlugin.py | 33 +++++++++------------------------ 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index a1859bc33..58208ba99 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -43,6 +43,11 @@ def roundtrip(tmp_path: Path, mode: str) -> None: im.save(outfile) converted = open_with_magick(magick, tmp_path, outfile) + if mode == "P": + assert converted.mode == "P" + + im = im.convert("RGB") + converted = converted.convert("RGB") assert_image_equal(converted, im) @@ -55,7 +60,6 @@ def test_monochrome(tmp_path: Path) -> None: roundtrip(tmp_path, mode) -@pytest.mark.xfail(reason="Palm P image is wrong") def test_p_mode(tmp_path: Path) -> None: # Arrange mode = "P" diff --git a/src/PIL/PalmImagePlugin.py b/src/PIL/PalmImagePlugin.py index b33245376..15f712908 100644 --- a/src/PIL/PalmImagePlugin.py +++ b/src/PIL/PalmImagePlugin.py @@ -116,9 +116,6 @@ _COMPRESSION_TYPES = {"none": 0xFF, "rle": 0x01, "scanline": 0x00} def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if im.mode == "P": - # we assume this is a color Palm image with the standard colormap, - # unless the "info" dict has a "custom-colormap" field - rawmode = "P" bpp = 8 version = 1 @@ -172,12 +169,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: compression_type = _COMPRESSION_TYPES["none"] flags = 0 - if im.mode == "P" and "custom-colormap" in im.info: - assert im.palette is not None - flags = flags & _FLAGS["custom-colormap"] - colormapsize = 4 * 256 + 2 - colormapmode = im.palette.mode - colormap = im.getdata().getpalette() + if im.mode == "P": + flags |= _FLAGS["custom-colormap"] + colormap = im.im.getpalette() + colors = len(colormap) // 3 + colormapsize = 4 * colors + 2 else: colormapsize = 0 @@ -196,22 +192,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: # now write colormap if necessary - if colormapsize > 0: - fp.write(o16b(256)) - for i in range(256): + if colormapsize: + fp.write(o16b(colors)) + for i in range(colors): fp.write(o8(i)) - if colormapmode == "RGB": - fp.write( - o8(colormap[3 * i]) - + o8(colormap[3 * i + 1]) - + o8(colormap[3 * i + 2]) - ) - elif colormapmode == "RGBA": - fp.write( - o8(colormap[4 * i]) - + o8(colormap[4 * i + 1]) - + o8(colormap[4 * i + 2]) - ) + fp.write(colormap[3 * i : 3 * i + 3]) # now convert data to raw form ImageFile._save( From bce83ac800dd70c8b49fff9662a2352b2a388a0b Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 30 Mar 2025 03:36:36 +1100 Subject: [PATCH 25/25] Enable mmap on PyPy (#8840) --- src/PIL/Image.py | 2 ++ src/PIL/ImageFile.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 67e068b06..662afadf4 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -621,6 +621,8 @@ class Image: more information. """ if getattr(self, "map", None): + if sys.platform == "win32" and hasattr(sys, "pypy_version_info"): + self.map.close() self.map: mmap.mmap | None = None # Instead of simply setting to None, we're setting up a diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index f5b72ee0d..a7848c369 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -34,7 +34,6 @@ import itertools import logging import os import struct -import sys from typing import IO, TYPE_CHECKING, Any, NamedTuple, cast from . import ExifTags, Image @@ -278,8 +277,6 @@ class ImageFile(Image.Image): self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 - # As of pypy 2.1.0, memory mapping was failing here. - use_mmap = use_mmap and not hasattr(sys, "pypy_version_info") readonly = 0