Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions recipes/flet-libwebp/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/bash
set -eu

# The image-format detection only feeds the cwebp/dwebp/gif2webp tools, which we
# delete; disabling it keeps configure from probing for host libpng/jpeg/tiff/gif.
# --prefix must be the real staging dir, not overridden at `make install` time:
# libwebp.la links libsharpyuv.la, and libtool refuses to install a shared library
# into a directory that disagrees with the -rpath it was linked with.
./configure --host=$HOST_TRIPLET --build=$BUILD_TRIPLET --prefix=$PREFIX \
--enable-libwebpmux --enable-libwebpdemux \
--disable-png --disable-jpeg --disable-tiff --disable-gif \
--disable-gl --disable-sdl --disable-wic
# libtool's linux defaults hardcode -rpath $PREFIX/lib into the shared libraries,
# baking the build machine's absolute path into a published wheel. Nothing on the
# device lives there; jniLibs resolves these by SONAME.
sed -i.bak 's|^hardcode_into_libs=.*|hardcode_into_libs=no|' libtool
# Fail loud if libtool ever renames the variable: the silent outcome is a
# published wheel with the build machine's paths in it.
grep -q '^hardcode_into_libs=no' libtool || { echo "libtool rpath patch failed"; exit 1; }

make -j $CPU_COUNT
make install

rm -rf $PREFIX/bin $PREFIX/share
rm -rf $PREFIX/lib/{*.la,pkgconfig}

# Android links these dynamically (Pillow's _webp.so gets a DT_NEEDED and flet
# stages the .so into jniLibs); iOS is static-only, and there libtool leaves
# sharpyuv out of libwebp.a -- Pillow's setup.py already adds -lsharpyuv there.
if [ $CROSS_VENV_SDK == "android" ]; then
rm -f $PREFIX/lib/*.a
fi
22 changes: 22 additions & 0 deletions recipes/flet-libwebp/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% set version = "1.6.0" %}

package:
name: flet-libwebp
version: '{{ version }}'

build:
number: 1

source:
url: https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-{{ version }}.tar.gz

patches:
- config.patch

about:
# PATENTS is the "additional intellectual property rights grant" every source
# header points at; it is not matched by the automatic COPYING/LICENSE search.
license_file:
- COPYING
- PATENTS
license: BSD-3-Clause
16 changes: 16 additions & 0 deletions recipes/flet-libwebp/patches/config.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
config.sub 2024-05-27 knows `*-apple-ios` but rejects the simulator triplets forge
uses (`arm64-apple-ios-simulator`, `x86_64-apple-ios-simulator`) at its final
kernel/OS consistency check. Accept `ios-simulator` there, as flet-libtiff does.

diff -ruN a/config.sub b/config.sub
--- a/config.sub 2025-07-09 22:53:34
+++ b/config.sub 2026-09-01 00:48:59
@@ -2259,6 +2259,8 @@
--*)
# Blank kernel and OS with real machine code file format is always fine.
;;
+ ios-simulator*-)
+ ;;
*-*-*)
echo "Invalid configuration '$1': Kernel '$kernel' not known to work with OS '$os'." 1>&2
exit 1
3 changes: 2 additions & 1 deletion recipes/pillow/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ requirements:
# PNG support is internal: libpng is not used.
- flet-libjpeg 3.0.90
- flet-libfreetype 2.13.3
- flet-libwebp 1.6.0

# {% if not version or version >= (12,0,0) %}
# pillow >= 12.x (12.x drops `self.add_imaging_libs = ""` from
Expand All @@ -25,7 +26,7 @@ patches:
# {% endif %}

build:
number: 1
number: 2
script_env:
# {% if sdk == 'android' %}
# pillow's setup.py manually probes `self.compiler.{include,library}_dirs`
Expand Down
146 changes: 146 additions & 0 deletions recipes/pillow/tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,149 @@ def test_font():
assert any(p != (255, 255, 255) for p in pixels), (
"font didn't render any non-white pixels"
)


def test_webp_available():
"""WebP support is compiled in (PIL._webp imports)."""
from PIL import Image, features

assert features.check("webp") is True
# Non-empty version string; not pinned, so a libwebp bump doesn't break this.
assert features.version("webp")
assert ".webp" in Image.registered_extensions()


def test_webp_no_unsupported_warning():
"""Opening a WebP emits no 'WEBP support not installed' warning."""
import warnings

from PIL import Image

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
img = Image.open(join(dirname(__file__), "webp_quadrants_lossy.webp"))
img.load()

assert not [w for w in caught if "WEBP support not installed" in str(w.message)]


def test_webp_decode_lossless_file():
"""Decode a committed lossless WebP; quadrant colours are exact."""
from PIL import Image

img = Image.open(join(dirname(__file__), "webp_quadrants_lossless.webp"))
assert img.size == (8, 8)
assert img.format == "WEBP"

rgb = img.convert("RGB")
assert rgb.getpixel((1, 1)) == (255, 0, 0)
assert rgb.getpixel((6, 1)) == (0, 255, 0)
assert rgb.getpixel((1, 6)) == (0, 0, 255)
assert rgb.getpixel((6, 6)) == (255, 255, 0)


def test_webp_decode_lossy_file():
"""Decode a committed lossy WebP (VP8, what CDNs serve) within tolerance."""
from PIL import Image

img = Image.open(join(dirname(__file__), "webp_quadrants_lossy.webp"))
assert img.size == (8, 8)

rgb = img.convert("RGB")
expected = {
(1, 1): (255, 0, 0),
(6, 1): (0, 255, 0),
(1, 6): (0, 0, 255),
(6, 6): (255, 255, 0),
}
for xy, want in expected.items():
got = rgb.getpixel(xy)
assert all(abs(g - w) <= 24 for g, w in zip(got, want)), f"{xy}: {got} != {want}"


def test_webp_lossless_roundtrip():
"""Encode and re-decode a lossless WebP without loss."""
from PIL import Image

src = Image.new("RGB", (16, 16))
src.putdata([(x * 16, y * 16, 0) for y in range(16) for x in range(16)])

out = io.BytesIO()
src.save(out, "WEBP", lossless=True)
data = out.getvalue()
assert data[:4] == b"RIFF"
assert data[8:12] == b"WEBP"
assert data[12:16] == b"VP8L"

rt = Image.open(io.BytesIO(data))
assert rt.convert("RGB").tobytes() == src.tobytes()


def test_webp_lossy_roundtrip_alpha():
"""Round-trip RGBA through the lossy encoder, preserving alpha."""
from PIL import Image

src = Image.new("RGBA", (16, 16), (0, 128, 255, 255))
for y in range(8):
for x in range(8):
src.putpixel((x, y), (0, 0, 0, 0))

out = io.BytesIO()
src.save(out, "WEBP", quality=80)
data = out.getvalue()
assert data[12:16] == b"VP8X"

rt = Image.open(io.BytesIO(data))
assert rt.mode == "RGBA"
assert rt.getpixel((2, 2))[3] == 0
assert rt.getpixel((12, 12))[3] == 255


def test_webp_metadata_roundtrip():
"""Save a still WebP with EXIF, proving libwebpmux is linked."""
from PIL import Image

payload = b"MM\x00*\x00\x00\x00\x08\x00\x00"
out = io.BytesIO()
Image.new("RGB", (8, 8), (10, 20, 30)).save(
out, "WEBP", lossless=True, exif=b"Exif\x00\x00" + payload
)

# Pillow strips the "Exif\0\0" prefix before writing the chunk; tolerate
# either convention so the assert pins the round-trip, not the framing.
got = Image.open(io.BytesIO(out.getvalue())).info.get("exif")
assert got is not None, "no exif chunk survived the round-trip"
assert got.removeprefix(b"Exif\x00\x00") == payload, got


def test_webp_animation_decode():
"""Read a committed animated WebP frame by frame (libwebpdemux)."""
from PIL import Image

img = Image.open(join(dirname(__file__), "webp_anim_rgb.webp"))
assert img.n_frames == 3
assert img.is_animated is True

for i, want in enumerate([(255, 0, 0), (0, 255, 0), (0, 0, 255)]):
img.seek(i)
assert img.convert("RGB").getpixel((4, 4)) == want
assert img.info["duration"] == 100


def test_webp_animation_encode():
"""Write a 3-frame animated WebP and read it back (libwebpmux)."""
from PIL import Image

frames = [Image.new("RGB", (8, 8), c) for c in ((255, 0, 0), (0, 255, 0), (0, 0, 255))]

out = io.BytesIO()
frames[0].save(
out, "WEBP", save_all=True, append_images=frames[1:],
duration=80, loop=0, lossless=True,
)

rt = Image.open(io.BytesIO(out.getvalue()))
assert rt.n_frames == 3
for i, want in enumerate([(255, 0, 0), (0, 255, 0), (0, 0, 255)]):
rt.seek(i)
assert rt.convert("RGB").getpixel((4, 4)) == want
Binary file added recipes/pillow/tests/webp_anim_rgb.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added recipes/pillow/tests/webp_quadrants_lossless.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added recipes/pillow/tests/webp_quadrants_lossy.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading