diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..d1d824335 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "labels": [ + "Dependency" + ], + "packageRules": [ + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "separateMajorMinor": "false" + } + ], + "schedule": ["on the 3rd day of the month"] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eeb4b391e..f81bcb956 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,7 @@ repos: rev: v4.3.0 hooks: - id: check-merge-conflict + - id: check-json - id: check-yaml - repo: https://github.com/sphinx-contrib/sphinx-lint diff --git a/CHANGES.rst b/CHANGES.rst index 1d4103a7c..8c2993abc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 9.3.0 (unreleased) ------------------ +- Corrected BMP and TGA palette size when saving #6500 + [radarhere] + - Do not call load() before draft() in Image.thumbnail #6539 [radarhere] diff --git a/MANIFEST.in b/MANIFEST.in index 26f9401f2..08f6dfc08 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -25,6 +25,7 @@ exclude .coveragerc exclude .editorconfig exclude .readthedocs.yml exclude codecov.yml +exclude renovate.json global-exclude .git* global-exclude *.pyc global-exclude *.so diff --git a/README.md b/README.md index 5e9adaf7e..e7c0ebc5a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ As of 2019, Pillow development is Number of PyPI downloads + OpenSSF Best Practices diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 604d54d88..f6860a9a4 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -58,6 +58,18 @@ def test_save_to_bytes(): assert reloaded.format == "BMP" +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 0, 0, 125, 125, 125, 255, 255, 255] + im.putpalette(colors) + + out = str(tmp_path / "temp.bmp") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + + def test_save_too_large(tmp_path): outfile = str(tmp_path / "temp.bmp") with Image.new("RGB", (1, 1)) as im: diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 68cb8a36e..4e967faec 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1087,6 +1087,19 @@ def test_palette_save_P(tmp_path): assert_image_equal(reloaded, im) +def test_palette_save_duplicate_entries(tmp_path): + im = Image.new("P", (1, 2)) + im.putpixel((0, 1), 1) + + im.putpalette((0, 0, 0, 0, 0, 0)) + + out = str(tmp_path / "temp.gif") + im.save(out, palette=[0, 0, 0, 0, 0, 0, 1, 1, 1]) + + with Image.open(out) as reloaded: + assert reloaded.convert("RGB").getpixel((0, 1)) == (0, 0, 0) + + def test_palette_save_all_P(tmp_path): frames = [] colors = ((255, 0, 0), (0, 255, 0)) diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index cbbb7df1d..7d8b5139a 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -120,6 +120,18 @@ def test_save(tmp_path): assert test_im.size == (100, 100) +def test_small_palette(tmp_path): + im = Image.new("P", (1, 1)) + colors = [0, 0, 0] + im.putpalette(colors) + + out = str(tmp_path / "temp.tga") + im.save(out) + + with Image.open(out) as reloaded: + assert reloaded.getpalette() == colors + + def test_save_wrong_mode(tmp_path): im = hopper("PA") out = str(tmp_path / "temp.tga") diff --git a/Tests/test_image.py b/Tests/test_image.py index 7cebed127..ab945e946 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -620,6 +620,7 @@ class TestImage: im_remapped = im.remap_palette([1, 0]) assert im_remapped.info["transparency"] == 1 + assert len(im_remapped.getpalette()) == 6 # Test unused transparency im.info["transparency"] = 2 diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 01e40e6d4..550578f8f 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -110,6 +110,16 @@ def test_contain(new_size): assert new_im.size == (256, 256) +def test_contain_round(): + im = Image.new("1", (43, 63), 1) + new_im = ImageOps.contain(im, (5, 7)) + assert new_im.width == 5 + + im = Image.new("1", (63, 43), 1) + new_im = ImageOps.contain(im, (7, 5)) + assert new_im.height == 5 + + def test_pad(): # Same ratio im = hopper() @@ -130,6 +140,15 @@ def test_pad(): ) +def test_pad_round(): + im = Image.new("1", (1, 1), 1) + new_im = ImageOps.pad(im, (4, 1)) + assert new_im.load()[2, 0] == 1 + + new_im = ImageOps.pad(im, (1, 4)) + assert new_im.load()[0, 2] == 1 + + def test_pil163(): # Division by zero in equalize if < 255 pixels in image (@PIL163) diff --git a/docs/index.rst b/docs/index.rst index c731e2746..45af4c571 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,6 +69,10 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more >> from PIL.ExifTags import TAGS diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 7bb73fc93..1041ab763 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -375,6 +375,16 @@ def _save(im, fp, filename, bitmap_header=True): header = 40 # or 64 for OS/2 version 2 image = stride * im.size[1] + if im.mode == "1": + palette = b"".join(o8(i) * 4 for i in (0, 255)) + elif im.mode == "L": + palette = b"".join(o8(i) * 4 for i in range(256)) + elif im.mode == "P": + palette = im.im.getpalette("RGB", "BGRX") + colors = len(palette) // 4 + else: + palette = None + # bitmap header if bitmap_header: offset = 14 + header + colors * 4 @@ -405,14 +415,8 @@ def _save(im, fp, filename, bitmap_header=True): fp.write(b"\0" * (header - 40)) # padding (for OS/2 format) - if im.mode == "1": - for i in (0, 255): - fp.write(o8(i) * 4) - elif im.mode == "L": - for i in range(256): - fp.write(o8(i) * 4) - elif im.mode == "P": - fp.write(im.im.getpalette("RGB", "BGRX")) + if palette: + fp.write(palette) ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 2e11df54c..20435fe31 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -519,9 +519,8 @@ def _normalize_palette(im, palette, info): used_palette_colors = [] for i in range(0, len(source_palette), 3): source_color = tuple(source_palette[i : i + 3]) - try: - index = im.palette.colors[source_color] - except KeyError: + index = im.palette.colors.get(source_color) + if index in used_palette_colors: index = None used_palette_colors.append(index) for i, index in enumerate(used_palette_colors): diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d2819e076..6611ceb3c 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1949,11 +1949,7 @@ class Image: m_im = m_im.convert("L") - # Internally, we require 256 palette entries. - new_palette_bytes = ( - palette_bytes + ((256 * bands) - len(palette_bytes)) * b"\x00" - ) - m_im.putpalette(new_palette_bytes, palette_mode) + m_im.putpalette(palette_bytes, palette_mode) m_im.palette = ImagePalette.ImagePalette(palette_mode, palette=palette_bytes) if "transparency" in self.info: diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index 0c3f900ca..ae43fc3bd 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -255,11 +255,11 @@ def contain(image, size, method=Image.Resampling.BICUBIC): if im_ratio != dest_ratio: if im_ratio > dest_ratio: - new_height = int(image.height / image.width * size[0]) + new_height = round(image.height / image.width * size[0]) if new_height != size[1]: size = (size[0], new_height) else: - new_width = int(image.width / image.height * size[1]) + new_width = round(image.width / image.height * size[1]) if new_width != size[0]: size = (new_width, size[1]) return image.resize(size, resample=method) @@ -292,10 +292,10 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5 else: out = Image.new(image.mode, size, color) if resized.width != size[0]: - x = int((size[0] - resized.width) * max(0, min(centering[0], 1))) + x = round((size[0] - resized.width) * max(0, min(centering[0], 1))) out.paste(resized, (x, 0)) else: - y = int((size[1] - resized.height) * max(0, min(centering[1], 1))) + y = round((size[1] - resized.height) * max(0, min(centering[1], 1))) out.paste(resized, (0, y)) return out diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 59b89e988..cd454b755 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -193,9 +193,10 @@ def _save(im, fp, filename): warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: - colormapfirst, colormaplength, colormapentry = 0, 256, 24 + palette = im.im.getpalette("RGB", "BGR") + colormaplength, colormapentry = len(palette) // 3, 24 else: - colormapfirst, colormaplength, colormapentry = 0, 0, 0 + colormaplength, colormapentry = 0, 0 if im.mode in ("LA", "RGBA"): flags = 8 @@ -210,7 +211,7 @@ def _save(im, fp, filename): o8(id_len) + o8(colormaptype) + o8(imagetype) - + o16(colormapfirst) + + o16(0) # colormapfirst + o16(colormaplength) + o8(colormapentry) + o16(0) @@ -225,7 +226,7 @@ def _save(im, fp, filename): fp.write(id_section) if colormaptype: - fp.write(im.im.getpalette("RGB", "BGR")) + fp.write(palette) if rle: ImageFile._save( diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index e289027fe..414857fc2 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -355,9 +355,9 @@ deps = { "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/5.1.0.zip", - "filename": "harfbuzz-5.1.0.zip", - "dir": "harfbuzz-5.1.0", + "url": "https://github.com/harfbuzz/harfbuzz/archive/5.2.0.zip", + "filename": "harfbuzz-5.2.0.zip", + "dir": "harfbuzz-5.2.0", "license": "COPYING", "build": [ cmd_cmake("-DHB_HAVE_FREETYPE:BOOL=TRUE"),