Merge branch 'main' into use-ptr

This commit is contained in:
Alexander Karpinsky 2024-09-18 22:24:46 +02:00 committed by GitHub
commit af521a1ce1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 500 additions and 239 deletions

View File

@ -21,7 +21,7 @@ set -e
if [[ $(uname) != CYGWIN* ]]; then
sudo apt-get -qq install libfreetype6-dev liblcms2-dev python3-tk\
ghostscript libffi-dev libjpeg-turbo-progs libopenjp2-7-dev\
ghostscript libjpeg-turbo-progs libopenjp2-7-dev\
cmake meson imagemagick libharfbuzz-dev libfribidi-dev\
sway wl-clipboard libopenblas-dev
fi
@ -38,12 +38,7 @@ python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma
if [[ $(uname) != CYGWIN* ]]; then
# TODO Update condition when NumPy supports free-threading
if [[ "$PYTHON_GIL" == "0" ]]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy
fi
# PyQt6 doesn't support PyPy3
if [[ $GHA_PYTHON_VERSION == 3.* ]]; then

View File

@ -1 +1 @@
cibuildwheel==2.20.0
cibuildwheel==2.21.1

View File

@ -37,7 +37,7 @@ jobs:
fail-fast: false
matrix:
os: [
"macos-14",
"macos-latest",
"ubuntu-latest",
]
python-version: [
@ -56,7 +56,7 @@ jobs:
# M1 only available for 3.10+
- { os: "macos-13", python-version: "3.9" }
exclude:
- { os: "macos-14", python-version: "3.9" }
- { os: "macos-latest", python-version: "3.9" }
runs-on: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.disable-gil && 'free-threaded' || '' }}
@ -76,7 +76,7 @@ jobs:
"pyproject.toml"
- name: Set up Python ${{ matrix.python-version }} (free-threaded)
uses: deadsnakes/action@v3.1.0
uses: deadsnakes/action@v3.2.0
if: "${{ matrix.disable-gil }}"
with:
python-version: ${{ matrix.python-version }}

View File

@ -16,11 +16,15 @@ ARCHIVE_SDIR=pillow-depends-main
# Package versions for fresh source builds
FREETYPE_VERSION=2.13.2
if [[ "$MB_ML_VER" != 2014 ]]; then
HARFBUZZ_VERSION=9.0.0
else
HARFBUZZ_VERSION=8.5.0
LIBPNG_VERSION=1.6.43
JPEGTURBO_VERSION=3.0.3
fi
LIBPNG_VERSION=1.6.44
JPEGTURBO_VERSION=3.0.4
OPENJPEG_VERSION=2.5.2
XZ_VERSION=5.4.5
XZ_VERSION=5.6.2
TIFF_VERSION=4.6.0
LCMS2_VERSION=2.16
if [[ -n "$IS_MACOS" ]]; then
@ -40,7 +44,7 @@ BROTLI_VERSION=1.1.0
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "x86_64" ]]; then
function build_openjpeg {
local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz)
local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v$OPENJPEG_VERSION.tar.gz openjpeg-$OPENJPEG_VERSION.tar.gz)
(cd $out_dir \
&& cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \
&& make install)
@ -50,7 +54,7 @@ fi
function build_brotli {
local cmake=$(get_modern_cmake)
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-1.1.0.tar.gz)
local out_dir=$(fetch_unpack https://github.com/google/brotli/archive/v$BROTLI_VERSION.tar.gz brotli-$BROTLI_VERSION.tar.gz)
(cd $out_dir \
&& $cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \
&& make install)
@ -60,6 +64,25 @@ function build_brotli {
fi
}
function build_harfbuzz {
if [[ "$HARFBUZZ_VERSION" == 8.5.0 ]]; then
export FREETYPE_LIBS=-lfreetype
export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/
build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no
export FREETYPE_LIBS=""
export FREETYPE_CFLAGS=""
else
local out_dir=$(fetch_unpack https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION/$HARFBUZZ_VERSION.tar.xz harfbuzz-$HARFBUZZ_VERSION.tar.xz)
(cd $out_dir \
&& meson setup build --buildtype=release -Dfreetype=enabled -Dglib=disabled)
(cd $out_dir/build \
&& meson install)
if [[ "$MB_ML_LIBC" == "manylinux" ]]; then
cp /usr/local/lib64/libharfbuzz* /usr/local/lib
fi
fi
}
function build {
if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then
sudo chown -R runner /usr/local
@ -109,15 +132,7 @@ function build {
build_freetype
fi
if [ -z "$IS_MACOS" ]; then
export FREETYPE_LIBS=-lfreetype
export FREETYPE_CFLAGS=-I/usr/local/include/freetype2/
fi
build_simple harfbuzz $HARFBUZZ_VERSION https://github.com/harfbuzz/harfbuzz/releases/download/$HARFBUZZ_VERSION tar.xz --with-freetype=yes --with-glib=no
if [ -z "$IS_MACOS" ]; then
export FREETYPE_LIBS=""
export FREETYPE_CFLAGS=""
fi
build_harfbuzz
}
# Any stuff that you need to do before you start building the wheels
@ -140,7 +155,13 @@ if [[ -n "$IS_MACOS" ]]; then
brew remove --ignore-dependencies webp
fi
brew install pkg-config
brew install meson pkg-config
elif [[ "$MB_ML_LIBC" == "manylinux" ]]; then
if [[ "$HARFBUZZ_VERSION" != 8.5.0 ]]; then
yum install -y meson
fi
else
apk add meson
fi
wrap_wheel_builder build

View File

@ -13,14 +13,7 @@ else
yum install -y fribidi
fi
if [ "${AUDITWHEEL_POLICY::9}" != "musllinux" ]; then
# TODO Update condition when NumPy supports free-threading
if [ $(python3 -c "import sysconfig;print(sysconfig.get_config_var('Py_GIL_DISABLED'))") == "1" ]; then
python3 -m pip install numpy --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple
else
python3 -m pip install numpy
fi
fi
if [ ! -d "test-images-main" ]; then
curl -fsSL -o pillow-test-images.zip https://github.com/python-pillow/test-images/archive/main.zip

View File

@ -102,12 +102,18 @@ jobs:
fail-fast: false
matrix:
include:
- name: "macOS x86_64"
- name: "macOS 10.10 x86_64"
os: macos-13
cibw_arch: x86_64
build: "pp310* cp3{9,10,11}*"
macosx_deployment_target: "10.10"
- name: "macOS 10.13 x86_64"
os: macos-13
cibw_arch: x86_64
build: "cp3{12,13}*"
macosx_deployment_target: "10.13"
- name: "macOS arm64"
os: macos-14
os: macos-latest
cibw_arch: arm64
macosx_deployment_target: "11.0"
- name: "manylinux2014 and musllinux x86_64"
@ -145,7 +151,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
name: dist-${{ matrix.os }}${{ matrix.macosx_deployment_target && format('-{0}', matrix.macosx_deployment_target) }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }}
path: ./wheelhouse/*.whl
windows:

View File

@ -5,6 +5,24 @@ Changelog (Pillow)
11.0.0 (unreleased)
-------------------
- Accept float stroke widths #8369
[radarhere]
- Deprecate ICNS (width, height, scale) sizes in favour of load(scale) #8352
[radarhere]
- Improved handling of RGBA palettes when saving GIF images #8366
[radarhere]
- Deprecate isImageType #8364
[radarhere]
- Support converting more modes to LAB by converting to RGBA first #8358
[radarhere]
- Deprecate support for FreeType 2.9.0 #8356
[hugovk, radarhere]
- Removed unused TiffImagePlugin IFD_LEGACY_API #8355
[radarhere]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1429,3 +1429,21 @@ def test_saving_rgba(tmp_path: Path) -> None:
with Image.open(out) as reloaded:
reloaded_rgba = reloaded.convert("RGBA")
assert reloaded_rgba.load()[0, 0][3] == 0
def test_optimizing_p_rgba(tmp_path: Path) -> None:
out = str(tmp_path / "temp.gif")
im1 = Image.new("P", (100, 100))
d = ImageDraw.Draw(im1)
d.ellipse([(40, 40), (60, 60)], fill=1)
data = [0, 0, 0, 0, 0, 0, 0, 255] + [0, 0, 0, 0] * 254
im1.putpalette(data, "RGBA")
im2 = Image.new("P", (100, 100))
im2.putpalette(data, "RGBA")
im1.save(out, save_all=True, append_images=[im2])
with Image.open(out) as reloaded:
assert reloaded.n_frames == 2

View File

@ -63,8 +63,8 @@ def test_save_append_images(tmp_path: Path) -> None:
assert_image_similar_tofile(im, temp_file, 1)
with Image.open(temp_file) as reread:
reread.size = (16, 16, 2)
reread.load()
reread.size = (16, 16)
reread.load(2)
assert_image_equal(reread, provided_im)
@ -87,14 +87,21 @@ def test_sizes() -> None:
for w, h, r in im.info["sizes"]:
wr = w * r
hr = h * r
with pytest.warns(DeprecationWarning):
im.size = (w, h, r)
im.load()
assert im.mode == "RGBA"
assert im.size == (wr, hr)
# Test using load() with scale
im.size = (w, h)
im.load(scale=r)
assert im.mode == "RGBA"
assert im.size == (wr, hr)
# Check that we cannot load an incorrect size
with pytest.raises(ValueError):
im.size = (1, 1)
im.size = (1, 2)
def test_older_icon() -> None:
@ -105,8 +112,8 @@ def test_older_icon() -> None:
wr = w * r
hr = h * r
with Image.open("Tests/images/pillow2.icns") as im2:
im2.size = (w, h, r)
im2.load()
im2.size = (w, h)
im2.load(r)
assert im2.mode == "RGBA"
assert im2.size == (wr, hr)
@ -122,8 +129,8 @@ def test_jp2_icon() -> None:
wr = w * r
hr = h * r
with Image.open("Tests/images/pillow3.icns") as im2:
im2.size = (w, h, r)
im2.load()
im2.size = (w, h)
im2.load(r)
assert im2.mode == "RGBA"
assert im2.size == (wr, hr)

View File

@ -1086,22 +1086,17 @@ class TestImage:
valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c
"""
with Image.open(os.path.join("Tests/images", path)) as im:
try:
with pytest.raises(OSError) as e:
im.load()
pytest.fail()
except OSError as e:
buffer_overrun = str(e) == "buffer overrun when reading image file"
truncated = "image file is truncated" in str(e)
buffer_overrun = str(e.value) == "buffer overrun when reading image file"
truncated = "image file is truncated" in str(e.value)
assert buffer_overrun or truncated
def test_fli_overrun2(self) -> None:
with Image.open("Tests/images/fli_overrun2.bin") as im:
try:
with pytest.raises(OSError, match="buffer overrun when reading image file"):
im.seek(1)
pytest.fail()
except OSError as e:
assert str(e) == "buffer overrun when reading image file"
def test_exit_fp(self) -> None:
with Image.new("L", (1, 1)) as im:
@ -1117,6 +1112,10 @@ class TestImage:
assert len(caplog.records) == 0
assert im.fp is None
def test_deprecation(self) -> None:
with pytest.warns(DeprecationWarning):
assert not Image.isImageType(None)
class TestImageBytes:
@pytest.mark.parametrize("mode", Image.MODES + ["BGR;15", "BGR;16", "BGR;24"])

View File

@ -49,5 +49,7 @@ def test_copy_zero() -> None:
@skip_unless_feature("libtiff")
def test_deepcopy() -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
out = copy.deepcopy(im)
assert out.size == (590, 88)

View File

@ -300,9 +300,7 @@ class TestImageResize:
im.resize((10, 10), "unknown")
@skip_unless_feature("libtiff")
def test_load_first(self) -> None:
# load() may change the size of the image
# Test that resize() is calling it before getting the size
def test_transposed(self) -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im:
im = im.resize((64, 64))
assert im.size == (64, 64)

View File

@ -92,15 +92,13 @@ def test_no_resize() -> None:
@skip_unless_feature("libtiff")
def test_load_first() -> None:
# load() may change the size of the image
# Test that thumbnail() is calling it before performing size calculations
def test_transposed() -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
im.thumbnail((64, 64))
assert im.size == (64, 10)
# Test thumbnail(), without draft(),
# on an image that is large enough once load() has changed the size
with Image.open("Tests/images/g4_orientation_5.tif") as im:
im.thumbnail((590, 88), reducing_gap=None)
assert im.size == (590, 88)

View File

@ -696,6 +696,12 @@ def test_rgb_lab(mode: str) -> None:
assert value[:3] == (0, 255, 255)
def test_cmyk_lab() -> None:
im = Image.new("CMYK", (1, 1))
converted_im = im.convert("LAB")
assert converted_im.getpixel((0, 0)) == (255, 128, 128)
def test_deprecation() -> None:
with pytest.warns(DeprecationWarning):
assert ImageCms.DESCRIPTION.strip().startswith("pyCMS")

View File

@ -1368,6 +1368,20 @@ def test_stroke() -> None:
)
@skip_unless_feature("freetype2")
def test_stroke_float() -> None:
# Arrange
im = Image.new("RGB", (120, 130))
draw = ImageDraw.Draw(im)
font = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 120)
# Act
draw.text((12, 12), "A", "#f00", font, stroke_width=0.5)
# Assert
assert_image_similar_tofile(im, "Tests/images/imagedraw_stroke_float.png", 3.1)
@skip_unless_feature("freetype2")
def test_stroke_descender() -> None:
# Arrange

View File

@ -5,6 +5,7 @@ import os
import re
import shutil
import sys
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Any, BinaryIO
@ -460,17 +461,43 @@ def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None:
assert mask.size == (108, 13)
def test_load_when_image_not_found() -> None:
with tempfile.NamedTemporaryFile(delete=False) as tmp:
pass
with pytest.raises(OSError) as e:
ImageFont.load(tmp.name)
os.unlink(tmp.name)
root = os.path.splitext(tmp.name)[0]
assert str(e.value) == f"cannot find glyph data file {root}.{{gif|pbm|png}}"
def test_load_path_not_found() -> None:
# Arrange
filename = "somefilenamethatdoesntexist.ttf"
# Act/Assert
with pytest.raises(OSError):
with pytest.raises(OSError) as e:
ImageFont.load_path(filename)
# The file doesn't exist, so don't suggest `load`
assert filename in str(e.value)
assert "did you mean" not in str(e.value)
with pytest.raises(OSError):
ImageFont.truetype(filename)
def test_load_path_existing_path() -> None:
with tempfile.NamedTemporaryFile() as tmp:
with pytest.raises(OSError) as e:
ImageFont.load_path(tmp.name)
# The file exists, so the error message suggests to use `load` instead
assert tmp.name in str(e.value)
assert " did you mean" in str(e.value)
def test_load_non_font_bytes() -> None:
with open("Tests/images/hopper.jpg", "rb") as f:
with pytest.raises(OSError):
@ -1150,3 +1177,15 @@ def test_invalid_truetype_sizes_raise_valueerror(
) -> None:
with pytest.raises(ValueError):
ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine)
def test_freetype_deprecation(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange: mock features.version_module to return fake FreeType version
def fake_version_module(module: str) -> str:
return "2.9.0"
monkeypatch.setattr(features, "version_module", fake_version_module)
# Act / Assert
with pytest.warns(DeprecationWarning):
ImageFont.truetype(FONT_PATH, FONT_SIZE)

View File

@ -115,7 +115,7 @@ def test_ipythonviewer() -> None:
test_viewer = viewer
break
else:
pytest.fail()
pytest.fail("IPythonViewer not found")
im = hopper()
assert test_viewer.show(im) == 1

View File

@ -60,6 +60,18 @@ class TestImageWinDib:
with pytest.raises(ValueError):
ImageWin.Dib(mode)
def test_dib_hwnd(self) -> None:
mode = "RGBA"
size = (128, 128)
wnd = 0
dib = ImageWin.Dib(mode, size)
hwnd = ImageWin.HWND(wnd)
dib.expose(hwnd)
dib.draw(hwnd, (0, 0) + size)
assert isinstance(dib.query_palette(hwnd), int)
def test_dib_paste(self) -> None:
# Arrange
im = hopper()

View File

@ -238,8 +238,10 @@ def test_zero_size() -> None:
@skip_unless_feature("libtiff")
def test_load_first() -> None:
def test_transposed() -> None:
with Image.open("Tests/images/g4_orientation_5.tif") as im:
assert im.size == (590, 88)
a = numpy.array(im)
assert a.shape == (88, 590)

View File

@ -109,6 +109,35 @@ ImageDraw.getdraw hints parameter
The ``hints`` parameter in :py:meth:`~PIL.ImageDraw.getdraw()` has been deprecated.
FreeType 2.9.0
^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0
(2025-10-15), when FreeType 2.9.1 will be the minimum supported.
We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe
vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/
ICNS (width, height, scale) sizes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Setting an ICNS image size to ``(width, height, scale)`` before loading has been
deprecated. Instead, ``load(scale)`` can be used.
Image isImageType()
^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)``
instead.
ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -324,12 +324,19 @@ sets the following :py:attr:`~PIL.Image.Image.info` property:
**sizes**
A list of supported sizes found in this icon file; these are a
3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina
icon and 1 for a standard icon. You *are* permitted to use this 3-tuple
format for the :py:attr:`~PIL.Image.Image.size` property if you set it
before calling :py:meth:`~PIL.Image.Image.load`; after loading, the size
will be reset to a 2-tuple containing pixel dimensions (so, e.g. if you
ask for ``(512, 512, 2)``, the final value of
:py:attr:`~PIL.Image.Image.size` will be ``(1024, 1024)``).
icon and 1 for a standard icon.
.. _icns-loading:
Loading
~~~~~~~
You can call the :py:meth:`~PIL.Image.Image.load` method with the following parameter.
**scale**
Affects the scale of the resultant image. If the size is set to ``(512, 512)``,
after loading at scale 2, the final value of :py:attr:`~PIL.Image.Image.size` will
be ``(1024, 1024)``.
.. _icns-saving:

View File

@ -23,6 +23,13 @@ Python 3.8
Pillow has dropped support for Python 3.8,
which reached end-of-life in October 2024.
Python 3.12 on macOS <= 10.12
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The latest version of Python 3.12 only supports macOS versions 10.13 and later,
and so Pillow has also updated the deployment target for its prebuilt Python 3.12
wheels.
PSFile
^^^^^^
@ -45,12 +52,48 @@ TiffImagePlugin IFD_LEGACY_API
An unused setting, ``TiffImagePlugin.IFD_LEGACY_API``, has been removed.
WebP 0.4
^^^^^^^^
Support for WebP 0.4 and earlier has been removed; WebP 0.5 is the minimum supported.
Deprecations
============
FreeType 2.9.0
^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Support for FreeType 2.9.0 is deprecated and will be removed in Pillow 12.0.0
(2025-10-15), when FreeType 2.9.1 will be the minimum supported.
We recommend upgrading to at least FreeType `2.10.4`_, which fixed a severe
vulnerability introduced in FreeType 2.6 (:cve:`2020-15999`).
.. _2.10.4: https://sourceforge.net/projects/freetype/files/freetype2/2.10.4/
ICNS (width, height, scale) sizes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
Setting an ICNS image size to ``(width, height, scale)`` before loading has been
deprecated. Instead, ``load(scale)`` can be used.
Image isImageType()
^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``Image.isImageType(im)`` has been deprecated. Use ``isinstance(im, Image.Image)``
instead.
ImageMath.lambda_eval and ImageMath.unsafe_eval options parameter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
The ``options`` parameter in :py:meth:`~PIL.ImageMath.lambda_eval()` and
:py:meth:`~PIL.ImageMath.unsafe_eval()` has been deprecated. One or more
keyword arguments can be used instead.
@ -66,6 +109,8 @@ have been deprecated, and will be removed in Pillow 12 (2025-10-15).
Specific WebP Feature Checks
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. deprecated:: 11.0.0
``features.check("transp_webp")``, ``features.check("webp_mux")`` and
``features.check("webp_anim")`` are now deprecated. They will always return
``True`` if the WebP module is installed, until they are removed in Pillow
@ -82,10 +127,18 @@ TODO
API Additions
=============
TODO
^^^^
Writing XMP bytes to JPEG and MPO
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TODO
XMP data can now be saved to JPEG files using an ``xmp`` argument::
im.save("out.jpg", xmp=b"test")
The data can also be set through :py:attr:`~PIL.Image.Image.info`, for use when saving
either JPEG or MPO images::
im.info["xmp"] = b"test"
im.save("out.jpg")
Other Changes
=============
@ -99,6 +152,10 @@ of 3.13.0 final (2024-10-01, :pep:`719`).
Pillow 11.0.0 now officially supports Python 3.13.
Support has also been added for the experimental free-threaded mode of :pep:`703`.
Python 3.13 only supports macOS versions 10.13 and later.
C-level Flags
^^^^^^^^^^^^^

View File

@ -125,7 +125,6 @@ lint.ignore = [
"PT007", # pytest-parametrize-values-wrong-type
"PT011", # pytest-raises-too-broad
"PT012", # pytest-raises-with-multiple-statements
"PT016", # pytest-fail-without-message
"PT017", # pytest-assert-in-except
"PYI026", # flake8-pyi: typing.TypeAlias added in Python 3.10
"PYI034", # flake8-pyi: typing.Self added in Python 3.11

View File

@ -553,7 +553,9 @@ def _normalize_palette(
if im.mode == "P":
if not source_palette:
source_palette = im.im.getpalette("RGB")[:768]
im_palette = im.getpalette(None)
assert im_palette is not None
source_palette = bytearray(im_palette)
else: # L-mode
if not source_palette:
source_palette = bytearray(i // 3 for i in range(768))
@ -629,7 +631,10 @@ def _write_single_frame(
def _getbbox(
base_im: Image.Image, im_frame: Image.Image
) -> tuple[Image.Image, tuple[int, int, int, int] | None]:
if _get_palette_bytes(im_frame) != _get_palette_bytes(base_im):
palette_bytes = [
bytes(im.palette.palette) if im.palette else b"" for im in (base_im, im_frame)
]
if palette_bytes[0] != palette_bytes[1]:
im_frame = im_frame.convert("RGBA")
base_im = base_im.convert("RGBA")
delta = ImageChops.subtract_modulo(im_frame, base_im)
@ -984,7 +989,13 @@ def _get_palette_bytes(im: Image.Image) -> bytes:
:param im: Image object
:returns: Bytes, len<=768 suitable for inclusion in gif header
"""
return bytes(im.palette.palette) if im.palette else b""
if not im.palette:
return b""
palette = bytes(im.palette.palette)
if im.palette.mode == "RGBA":
palette = b"".join(palette[i * 4 : i * 4 + 3] for i in range(len(palette) // 3))
return palette
def _get_background(

View File

@ -25,6 +25,7 @@ import sys
from typing import IO
from . import Image, ImageFile, PngImagePlugin, features
from ._deprecate import deprecate
enable_jpeg2k = features.check_codec("jpg_2000")
if enable_jpeg2k:
@ -275,37 +276,37 @@ class IcnsImageFile(ImageFile.ImageFile):
self.best_size[1] * self.best_size[2],
)
@property
def size(self):
@property # type: ignore[override]
def size(self) -> tuple[int, int] | tuple[int, int, int]:
return self._size
@size.setter
def size(self, value) -> None:
info_size = value
if info_size not in self.info["sizes"] and len(info_size) == 2:
info_size = (info_size[0], info_size[1], 1)
if (
info_size not in self.info["sizes"]
and len(info_size) == 3
and info_size[2] == 1
):
simple_sizes = [
(size[0] * size[2], size[1] * size[2]) for size in self.info["sizes"]
]
if value in simple_sizes:
info_size = self.info["sizes"][simple_sizes.index(value)]
if info_size not in self.info["sizes"]:
def size(self, value: tuple[int, int] | tuple[int, int, int]) -> None:
if len(value) == 3:
deprecate("Setting size to (width, height, scale)", 12, "load(scale)")
if value in self.info["sizes"]:
self._size = value # type: ignore[assignment]
return
else:
# Check that a matching size exists,
# or that there is a scale that would create a size that matches
for size in self.info["sizes"]:
simple_size = size[0] * size[2], size[1] * size[2]
scale = simple_size[0] // value[0]
if simple_size[1] / value[1] == scale:
self._size = value
return
msg = "This is not one of the allowed sizes of this image"
raise ValueError(msg)
self._size = value
def load(self) -> Image.core.PixelAccess | None:
if len(self.size) == 3:
self.best_size = self.size
self.size = (
self.best_size[0] * self.best_size[2],
self.best_size[1] * self.best_size[2],
)
def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
if scale is not None or len(self.size) == 3:
if scale is None and len(self.size) == 3:
scale = self.size[2]
assert scale is not None
width, height = self.size[:2]
self.size = width * scale, height * scale
self.best_size = width, height, scale
px = Image.Image.load(self)
if self._im is not None and self.im.size == self.size:

View File

@ -133,6 +133,7 @@ def isImageType(t: Any) -> TypeGuard[Image]:
:param t: object to check if it's an image
:returns: True if the object is an image
"""
deprecate("Image.isImageType(im)", 12, "isinstance(im, Image.Image)")
return hasattr(t, "im")
@ -1061,7 +1062,7 @@ class Image:
trns_im = new(self.mode, (1, 1))
if self.mode == "P":
assert self.palette is not None
trns_im.putpalette(self.palette)
trns_im.putpalette(self.palette, self.palette.mode)
if isinstance(t, tuple):
err = "Couldn't allocate a palette color for transparency"
assert trns_im.palette is not None
@ -1125,17 +1126,23 @@ class Image:
return new_im
if "LAB" in (self.mode, mode):
other_mode = mode if self.mode == "LAB" else self.mode
im = self
if mode == "LAB":
if im.mode not in ("RGB", "RGBA", "RGBX"):
im = im.convert("RGBA")
other_mode = im.mode
else:
other_mode = mode
if other_mode in ("RGB", "RGBA", "RGBX"):
from . import ImageCms
srgb = ImageCms.createProfile("sRGB")
lab = ImageCms.createProfile("LAB")
profiles = [lab, srgb] if self.mode == "LAB" else [srgb, lab]
profiles = [lab, srgb] if im.mode == "LAB" else [srgb, lab]
transform = ImageCms.buildTransform(
profiles[0], profiles[1], self.mode, mode
profiles[0], profiles[1], im.mode, mode
)
return transform.apply(self)
return transform.apply(im)
# colorspace conversion
if dither is None:
@ -1812,23 +1819,22 @@ class Image:
:param mask: An optional mask image.
"""
if isImageType(box):
if isinstance(box, Image):
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)
if len(box) == 2:
# upper left corner given; get size from image or mask
if isImageType(im):
if isinstance(im, Image):
size = im.size
elif isImageType(mask):
elif isinstance(mask, Image):
size = mask.size
else:
# FIXME: use self.size here?
@ -1841,17 +1847,15 @@ class Image:
from . import ImageColor
source = ImageColor.getcolor(im, self.mode)
elif isImageType(im):
elif isinstance(im, Image):
im.load()
if self.mode != im.mode:
if self.mode != "RGB" or im.mode not in ("LA", "RGBA", "RGBa"):
# should use an adapter for this!
im = im.convert(self.mode)
source = im.im
elif isinstance(im, tuple):
source = im
else:
source = cast(float, im)
source = im
self._ensure_mutable()
@ -2012,7 +2016,7 @@ class Image:
else:
band = 3
if isImageType(alpha):
if isinstance(alpha, Image):
# alpha layer
if alpha.mode not in ("1", "L"):
msg = "illegal image mode"
@ -2022,7 +2026,6 @@ 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):
@ -2171,6 +2174,9 @@ class Image:
source_palette = self.im.getpalette(palette_mode, palette_mode)
else: # L-mode
source_palette = bytearray(i // 3 for i in range(768))
elif len(source_palette) > 768:
bands = 4
palette_mode = "RGBA"
palette_bytes = b""
new_positions = [0] * 256
@ -2321,7 +2327,6 @@ class Image:
msg = "reducing_gap must be 1.0 or greater"
raise ValueError(msg)
self.load()
if box is None:
box = (0, 0) + self.size
@ -2770,27 +2775,18 @@ 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
final_size = preserved_size
box = None
if reducing_gap is not None:
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:
self.load()
# load() may have changed the size of the image
preserved_size = preserve_aspect_ratio()
if preserved_size is None:
return
final_size = preserved_size
if self.size != final_size:
im = self.resize(final_size, resample, box=box, reducing_gap=reducing_gap)

View File

@ -322,7 +322,7 @@ class ImageFile(Image.Image):
def load_prepare(self) -> None:
# create image memory if necessary
if self._im is None or self.im.mode != self.mode or self.im.size != self.size:
if self._im is None:
self.im = Image.core.new(self.mode, self.size)
# create palette (optional)
if self.mode == "P":

View File

@ -36,7 +36,7 @@ from io import BytesIO
from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, BinaryIO, TypedDict, cast
from . import Image
from . import Image, features
from ._typing import StrOrBytesPath
from ._util import DeferredError, is_path
@ -98,11 +98,13 @@ class ImageFont:
def _load_pilfont(self, filename: str) -> None:
with open(filename, "rb") as fp:
image: ImageFile.ImageFile | None = None
root = os.path.splitext(filename)[0]
for ext in (".png", ".gif", ".pbm"):
if image:
image.close()
try:
fullname = os.path.splitext(filename)[0] + ext
fullname = root + ext
image = Image.open(fullname)
except Exception:
pass
@ -112,7 +114,8 @@ class ImageFont:
else:
if image:
image.close()
msg = "cannot find glyph data file"
msg = f"cannot find glyph data file {root}.{{gif|pbm|png}}"
raise OSError(msg)
self.file = fullname
@ -224,7 +227,7 @@ class FreeTypeFont:
raise core.ex
if size <= 0:
msg = "font size must be greater than 0"
msg = f"font size must be greater than 0, not {size}"
raise ValueError(msg)
self.path = font
@ -232,6 +235,21 @@ class FreeTypeFont:
self.index = index
self.encoding = encoding
try:
from packaging.version import parse as parse_version
except ImportError:
pass
else:
if freetype_version := features.version_module("freetype2"):
if parse_version(freetype_version) < parse_version("2.9.1"):
warnings.warn(
"Support for FreeType 2.9.0 is deprecated and will be removed "
"in Pillow 12 (2025-10-15). Please upgrade to FreeType 2.9.1 "
"or newer, preferably FreeType 2.10.4 which fixes "
"CVE-2020-15999.",
DeprecationWarning,
)
if layout_engine not in (Layout.BASIC, Layout.RAQM):
layout_engine = Layout.BASIC
if core.HAVE_RAQM:
@ -769,7 +787,8 @@ class TransposedFont:
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.
bitmap font file, and returns the corresponding font object. For loading TrueType
or OpenType fonts instead, see :py:func:`~PIL.ImageFont.truetype`.
:param filename: Name of font file.
:return: A font object.
@ -789,9 +808,10 @@ def truetype(
) -> FreeTypeFont:
"""
Load a TrueType or OpenType font from a file or file-like object,
and create a font object.
This function loads a font object from the given file or file-like
object, and creates a font object for a font of the given size.
and create a font object. This function loads a font object from the given
file or file-like object, and creates a font object for a font of the given
size. For loading bitmap fonts instead, see :py:func:`~PIL.ImageFont.load`
and :py:func:`~PIL.ImageFont.load_path`.
Pillow uses FreeType to open font files. On Windows, be aware that FreeType
will keep the file open as long as the FreeTypeFont object exists. Windows
@ -927,7 +947,10 @@ def load_path(filename: str | bytes) -> ImageFont:
return load(os.path.join(directory, filename))
except OSError:
pass
msg = "cannot find font file"
msg = f'cannot find font file "{filename}" in sys.path'
if os.path.exists(filename):
msg += f', did you mean ImageFont.load("{filename}") instead?'
raise OSError(msg)

View File

@ -265,7 +265,7 @@ def lambda_eval(
args.update(options)
args.update(kw)
for k, v in args.items():
if hasattr(v, "im"):
if isinstance(v, Image.Image):
args[k] = _Operand(v)
out = expression(args)
@ -316,7 +316,7 @@ def unsafe_eval(
args.update(options)
args.update(kw)
for k, v in args.items():
if hasattr(v, "im"):
if isinstance(v, Image.Image):
args[k] = _Operand(v)
compiled_code = compile(expression, "<string>", "eval")

View File

@ -98,14 +98,15 @@ class Dib:
HDC or HWND instance. In PythonWin, you can use
``CDC.GetHandleAttrib()`` to get a suitable handle.
"""
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle)
dc = self.image.getdc(handle_int)
try:
self.image.expose(dc)
finally:
self.image.releasedc(handle, dc)
self.image.releasedc(handle_int, dc)
else:
self.image.expose(handle)
self.image.expose(handle_int)
def draw(
self,
@ -124,14 +125,15 @@ class Dib:
"""
if src is None:
src = (0, 0) + self.size
handle_int = int(handle)
if isinstance(handle, HWND):
dc = self.image.getdc(handle)
dc = self.image.getdc(handle_int)
try:
self.image.draw(dc, dst, src)
finally:
self.image.releasedc(handle, dc)
self.image.releasedc(handle_int, dc)
else:
self.image.draw(handle, dst, src)
self.image.draw(handle_int, dst, src)
def query_palette(self, handle: int | HDC | HWND) -> int:
"""
@ -148,14 +150,15 @@ class Dib:
:return: The number of entries that were changed (if one or more entries,
this indicates that the image should be redrawn).
"""
handle_int = int(handle)
if isinstance(handle, HWND):
handle = self.image.getdc(handle)
handle = self.image.getdc(handle_int)
try:
result = self.image.query_palette(handle)
finally:
self.image.releasedc(handle, handle)
else:
result = self.image.query_palette(handle)
result = self.image.query_palette(handle_int)
return result
def paste(

View File

@ -140,7 +140,7 @@ def _safe_zlib_decompress(s: bytes) -> bytes:
dobj = zlib.decompressobj()
plaintext = dobj.decompress(s, MAX_TEXT_CHUNK)
if dobj.unconsumed_tail:
msg = "Decompressed Data Too Large"
msg = "Decompressed data too large for PngImagePlugin.MAX_TEXT_CHUNK"
raise ValueError(msg)
return plaintext

View File

@ -1194,8 +1194,8 @@ class TiffImageFile(ImageFile.ImageFile):
# Create a new core image object on second and
# subsequent frames in the image. Image may be
# different size/mode.
Image._decompression_bomb_check(self.size)
self.im = Image.core.new(self.mode, self.size)
Image._decompression_bomb_check(self._tile_size)
self.im = Image.core.new(self.mode, self._tile_size)
def _seek(self, frame: int) -> None:
self.fp = self._fp
@ -1275,6 +1275,11 @@ class TiffImageFile(ImageFile.ImageFile):
return self._load_libtiff()
return super().load()
def load_prepare(self) -> None:
if self._im is None:
self.im = Image.core.new(self.mode, self._tile_size)
ImageFile.ImageFile.load_prepare(self)
def load_end(self) -> None:
# allow closing if we're on the first frame, there's no next
# This is the ImageFile.load path only, libtiff specific below.
@ -1416,6 +1421,11 @@ class TiffImageFile(ImageFile.ImageFile):
if not isinstance(xsize, int) or not isinstance(ysize, int):
msg = "Invalid dimensions"
raise ValueError(msg)
self._tile_size = xsize, ysize
orientation = self.tag_v2.get(ExifTags.Base.Orientation)
if orientation in (5, 6, 7, 8):
self._size = ysize, xsize
else:
self._size = xsize, ysize
logger.debug("- size: %s", self.size)
@ -1559,7 +1569,7 @@ class TiffImageFile(ImageFile.ImageFile):
if STRIPOFFSETS in self.tag_v2:
offsets = self.tag_v2[STRIPOFFSETS]
h = self.tag_v2.get(ROWSPERSTRIP, ysize)
w = self.size[0]
w = xsize
else:
# tiled image
offsets = self.tag_v2[TILEOFFSETS]
@ -1593,9 +1603,9 @@ class TiffImageFile(ImageFile.ImageFile):
)
)
x = x + w
if x >= self.size[0]:
if x >= xsize:
x, y = 0, y + h
if y >= self.size[1]:
if y >= ysize:
x = y = 0
layer += 1
else:

View File

@ -832,7 +832,7 @@ font_render(FontObject *self, PyObject *args) {
Imaging im;
int mask = 0; /* is FT_LOAD_TARGET_MONO enabled? */
int color = 0; /* is FT_LOAD_COLOR enabled? */
int stroke_width = 0;
float stroke_width = 0;
PY_LONG_LONG foreground_ink_long = 0;
unsigned int foreground_ink;
const char *mode = NULL;
@ -852,7 +852,7 @@ font_render(FontObject *self, PyObject *args) {
if (!PyArg_ParseTuple(
args,
"OO|zzOzizLffO:render",
"OO|zzOzfzLffO:render",
&string,
&fill,
&mode,
@ -918,8 +918,8 @@ font_render(FontObject *self, PyObject *args) {
return NULL;
}
width += stroke_width * 2 + ceil(x_start);
height += stroke_width * 2 + ceil(y_start);
width += ceil(stroke_width * 2 + x_start);
height += ceil(stroke_width * 2 + y_start);
image = PyObject_CallFunction(fill, "ii", width, height);
if (image == Py_None) {
PyMem_Del(glyph_info);
@ -932,8 +932,8 @@ font_render(FontObject *self, PyObject *args) {
im = (Imaging)PyCapsule_GetPointer(imagePtr, IMAGING_MAGIC);
Py_XDECREF(imagePtr);
x_offset -= stroke_width;
y_offset -= stroke_width;
x_offset = round(x_offset - stroke_width);
y_offset = round(y_offset - stroke_width);
if (count == 0 || width == 0 || height == 0) {
PyMem_Del(glyph_info);
return Py_BuildValue("N(ii)", image, x_offset, y_offset);
@ -948,7 +948,7 @@ font_render(FontObject *self, PyObject *args) {
FT_Stroker_Set(
stroker,
(FT_Fixed)stroke_width * 64,
(FT_Fixed)round(stroke_width * 64),
FT_STROKER_LINECAP_ROUND,
FT_STROKER_LINEJOIN_ROUND,
0
@ -986,8 +986,8 @@ font_render(FontObject *self, PyObject *args) {
}
/* set pen position to text origin */
x = (-x_min + stroke_width + x_start) * 64;
y = (-y_max + (-stroke_width) - y_start) * 64;
x = round((-x_min + stroke_width + x_start) * 64);
y = round((-y_max + (-stroke_width) - y_start) * 64);
if (stroker == NULL) {
load_flags |= FT_LOAD_RENDER;

View File

@ -1411,10 +1411,3 @@ PyImaging_Jpeg2KEncoderNew(PyObject *self, PyObject *args) {
}
#endif
/*
* Local Variables:
* c-basic-offset: 4
* End:
*
*/

View File

@ -31,7 +31,27 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) {
bbox[2] = bbox[3] = 0;
#define GETBBOX(image, mask) \
/* first stage: looking for any pixels from top */ \
for (y = 0; y < im->ysize; y++) { \
has_data = 0; \
for (x = 0; x < im->xsize; x++) { \
if (im->image[y][x] & mask) { \
has_data = 1; \
bbox[0] = x; \
bbox[1] = y; \
break; \
} \
} \
if (has_data) { \
break; \
} \
} \
/* Check that we have a box */ \
if (bbox[1] < 0) { \
return 0; /* no data */ \
} \
/* second stage: looking for any pixels from bottom */ \
for (y = im->ysize - 1; y >= bbox[1]; y--) { \
has_data = 0; \
for (x = 0; x < im->xsize; x++) { \
if (im->image[y][x] & mask) { \
@ -39,16 +59,27 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) {
if (x < bbox[0]) { \
bbox[0] = x; \
} \
if (x >= bbox[2]) { \
bbox[2] = x + 1; \
} \
bbox[3] = y + 1; \
break; \
} \
} \
if (has_data) { \
if (bbox[1] < 0) { \
bbox[1] = y; \
break; \
} \
} \
/* third stage: looking for left and right boundaries */ \
for (y = bbox[1]; y < bbox[3]; y++) { \
for (x = 0; x < bbox[0]; x++) { \
if (im->image[y][x] & mask) { \
bbox[0] = x; \
break; \
} \
} \
for (x = im->xsize - 1; x >= bbox[2]; x--) { \
if (im->image[y][x] & mask) { \
bbox[2] = x + 1; \
break; \
} \
bbox[3] = y + 1; \
} \
}
@ -71,11 +102,6 @@ ImagingGetBBox(Imaging im, int bbox[4], int alpha_only) {
GETBBOX(image32, mask);
}
/* Check that we got a box */
if (bbox[1] < 0) {
return 0; /* no data */
}
return 1; /* ok */
}
@ -144,6 +170,9 @@ ImagingGetExtrema(Imaging im, void *extrema) {
imax = in[x];
}
}
if (imin == 0 && imax == 255) {
break;
}
}
((UINT8 *)extrema)[0] = (UINT8)imin;
((UINT8 *)extrema)[1] = (UINT8)imax;

View File

@ -104,10 +104,3 @@ typedef struct {
int plt;
} JPEG2KENCODESTATE;
/*
* Local Variables:
* c-basic-offset: 4
* End:
*
*/

View File

@ -979,10 +979,3 @@ ImagingJpeg2KVersion(void) {
}
#endif /* HAVE_OPENJPEG */
/*
* Local Variables:
* c-basic-offset: 4
* End:
*
*/

View File

@ -652,10 +652,3 @@ ImagingJpeg2KEncodeCleanup(ImagingCodecState state) {
}
#endif /* HAVE_OPENJPEG */
/*
* Local Variables:
* c-basic-offset: 4
* End:
*
*/

View File

@ -44,7 +44,6 @@ PyImaging_GetBuffer(PyObject *buffer, Py_buffer *view);
typedef struct {
PyObject_HEAD Py_ssize_t count;
double *xy;
int index; /* temporary use, e.g. in decimate */
} PyPathObject;
static PyTypeObject PyPathType;

View File

@ -1,11 +1,11 @@
README
------
[cibuildwheel](https://github.com/pypa/cibuildwheel) is used to build macOS and Linux
wheels for tagged versions of Pillow.
[cibuildwheel](https://github.com/pypa/cibuildwheel) is used to build wheels for tagged
versions of Pillow.
This directory contains [multibuild](https://github.com/multi-build/multibuild) to
build dependencies for the wheels, and dependency licenses to be included.
build dependencies for macOS and Linux wheels, and dependency licenses to be included.
Archives
--------
@ -30,6 +30,3 @@ Wheels
Wheels are
[GitHub Actions artifacts created for tags, relevant changes or manual builds](https://github.com/python-pillow/Pillow/actions/workflows/wheels.yml).
Windows wheels are created separately. They are
[GitHub Actions artifacts created on each run of the Windows workflow](https://github.com/python-pillow/Pillow/actions/workflows/test-windows.yml?query=branch%3Amain).

View File

@ -113,13 +113,13 @@ V = {
"FREETYPE": "2.13.3",
"FRIBIDI": "1.0.15",
"HARFBUZZ": "9.0.0",
"JPEGTURBO": "3.0.3",
"JPEGTURBO": "3.0.4",
"LCMS2": "2.16",
"LIBPNG": "1.6.43",
"LIBPNG": "1.6.44",
"LIBWEBP": "1.4.0",
"OPENJPEG": "2.5.2",
"TIFF": "4.6.0",
"XZ": "5.4.5",
"XZ": "5.6.2",
"ZLIB": "1.3.1",
}
V["LIBPNG_DOTLESS"] = V["LIBPNG"].replace(".", "")
@ -175,7 +175,7 @@ DEPS: dict[str, dict[str, Any]] = {
"libs": [r"*.lib"],
},
"xz": {
"url": f"{SF_PROJECTS}/lzmautils/files/xz-{V['XZ']}.tar.gz/download",
"url": f"https://github.com/tukaani-project/xz/releases/download/v{V['XZ']}/xz-{V['XZ']}.tar.gz",
"filename": f"xz-{V['XZ']}.tar.gz",
"dir": f"xz-{V['XZ']}",
"license": "COPYING",