diff --git a/recipes/flet-libwebp/build.sh b/recipes/flet-libwebp/build.sh new file mode 100755 index 00000000..7f6b6864 --- /dev/null +++ b/recipes/flet-libwebp/build.sh @@ -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 diff --git a/recipes/flet-libwebp/meta.yaml b/recipes/flet-libwebp/meta.yaml new file mode 100644 index 00000000..0fe2c9d2 --- /dev/null +++ b/recipes/flet-libwebp/meta.yaml @@ -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 diff --git a/recipes/flet-libwebp/patches/config.patch b/recipes/flet-libwebp/patches/config.patch new file mode 100644 index 00000000..cfc95353 --- /dev/null +++ b/recipes/flet-libwebp/patches/config.patch @@ -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 diff --git a/recipes/pillow/meta.yaml b/recipes/pillow/meta.yaml index 40cc6293..0031ab5c 100644 --- a/recipes/pillow/meta.yaml +++ b/recipes/pillow/meta.yaml @@ -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 @@ -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` diff --git a/recipes/pillow/tests/test_pillow.py b/recipes/pillow/tests/test_pillow.py index 7873d51e..9f186af7 100644 --- a/recipes/pillow/tests/test_pillow.py +++ b/recipes/pillow/tests/test_pillow.py @@ -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 diff --git a/recipes/pillow/tests/webp_anim_rgb.webp b/recipes/pillow/tests/webp_anim_rgb.webp new file mode 100644 index 00000000..a0cf1300 Binary files /dev/null and b/recipes/pillow/tests/webp_anim_rgb.webp differ diff --git a/recipes/pillow/tests/webp_quadrants_lossless.webp b/recipes/pillow/tests/webp_quadrants_lossless.webp new file mode 100644 index 00000000..3dce833b Binary files /dev/null and b/recipes/pillow/tests/webp_quadrants_lossless.webp differ diff --git a/recipes/pillow/tests/webp_quadrants_lossy.webp b/recipes/pillow/tests/webp_quadrants_lossy.webp new file mode 100644 index 00000000..1e205200 Binary files /dev/null and b/recipes/pillow/tests/webp_quadrants_lossy.webp differ