From e2651becb68eb1e138f58ffa388dafbf1e68bc5b Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 1 Sep 2026 14:47:50 +0200 Subject: [PATCH 1/2] recipe: flet-libwebp 1.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds libwebp so Pillow (and anything else that wants it) can read and write WebP on device. 1.6.0 is not the newest for its own sake — it is the version Pillow 12.2.0 pins in depends/install_webp.sh, so we track what upstream tests against. Autotools, not CMake, and the reason is Android rather than taste. libwebp's CMakeLists sets SOVERSION unconditionally, producing libwebp.so.7, which APK packaging will not carry; configure goes through libtool, whose linux*android* case sets version_type=none and yields a bare libwebp.so. The Google release tarball is a `make dist` artifact with a pre-generated configure, so there is no autoreconf or autopoint step to go wrong. Two lines in build.sh are load-bearing, each having fixed a real failure: --prefix at configure time, not `make install prefix=`. The latter passed on all three iOS slices and failed on all three Android ones with "cannot install 'libwebp.la' to a directory not ending in /usr/local/lib" — iOS is static-only so libtool never relinks, while Android's libwebp.la links libsharpyuv.la and trips libtool's rpath consistency check. hardcode_into_libs=no. Without it every Android .so carried RUNPATH /Users//.../wheel/opt/lib, which on CI would publish /home/runner/work/... inside the wheel. A grep guard follows the sed, because the silent failure mode is a published wheel with build-machine paths in it. Android ships the shared libraries and iOS the static ones, matching how each platform resolves them: flet stages .so files into jniLibs, while iOS absorbs the archives into the consuming extension. That split also disposes of the sharpyuv question — on Android libwebp.so declares libsharpyuv.so as its own DT_NEEDED so the symbols resolve transitively, and on iOS Pillow's setup.py already appends -lsharpyuv under `sys.platform == "ios"`. No consumer patch is needed on either. licence_file names COPYING and PATENTS explicitly: PATENTS is the additional IP grant every source header points at, and the automatic search matches COPYING* but not it. 6/6 slices green, including armeabi-v7a. --- recipes/flet-libwebp/build.sh | 32 +++++++++++++++++++++++ recipes/flet-libwebp/meta.yaml | 22 ++++++++++++++++ recipes/flet-libwebp/patches/config.patch | 16 ++++++++++++ 3 files changed, 70 insertions(+) create mode 100755 recipes/flet-libwebp/build.sh create mode 100644 recipes/flet-libwebp/meta.yaml create mode 100644 recipes/flet-libwebp/patches/config.patch 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 From d4d253e8573efc53087a8cb8bbff43a6b595e8c5 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Tue, 1 Sep 2026 14:47:50 +0200 Subject: [PATCH 2/2] recipe: Pillow WebP support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers https://github.com/flet-dev/flet/discussions/6806. Our Pillow shipped PIL/_webp.pyi and no PIL/_webp.*.so, so Image.open() on a WebP warned "WEBP support not installed" and then raised UnidentifiedImageError — the requester's diagnosis was correct. Two lines. WebP was never force-disabled the way brotli, raqm and fribidi are in setup-12.x.patch; it was off only because feature detection found no libwebp. Declaring the host dep is enough — the recipe already feeds CPATH/LIBRARY_PATH at {platlib}/opt/{include,lib} on Android and iOS resolves through sysconfig — so the patch is untouched. Nine tests, network-free, with three fixtures totalling 382 bytes. They cover more than "it links": lossless decode asserts exact quadrant colours, lossy decode allows a VP8 tolerance, and the round-trip tests check the RIFF container tag (VP8L for lossless, VP8X for the alpha case). The EXIF test exercises libwebpmux and the animation tests exercise libwebpdemux and libwebpmux, so all three sub-libraries are proven at runtime rather than merely present on the link line. Verified 11/11 EXIT 0 on an Android arm64 emulator and an iOS 18.6 simulator, and 11/11 on desktop Pillow so the assertions are not device-specific. The Android _webp.so links libwebp/libwebpmux/libwebpdemux and the four libraries stage into lib/arm64-v8a/; the iOS extension absorbs them statically, 634 KB against Android's 27 KB, linking only system libraries. build.number 2: the upstream version is unchanged, the recipe is not. --- recipes/pillow/meta.yaml | 3 +- recipes/pillow/tests/test_pillow.py | 146 ++++++++++++++++++ recipes/pillow/tests/webp_anim_rgb.webp | Bin 0 -> 188 bytes .../pillow/tests/webp_quadrants_lossless.webp | Bin 0 -> 62 bytes .../pillow/tests/webp_quadrants_lossy.webp | Bin 0 -> 132 bytes 5 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 recipes/pillow/tests/webp_anim_rgb.webp create mode 100644 recipes/pillow/tests/webp_quadrants_lossless.webp create mode 100644 recipes/pillow/tests/webp_quadrants_lossy.webp 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 0000000000000000000000000000000000000000..a0cf130098f85e276b2bb99d3adfcd826cbf9709 GIT binary patch literal 188 zcmWIYbaUInz`zjh>J$(bU=hIuWHSLVI~Y0odHS*edH;ccfx*$w*G&V+g8+z%6rggT pJ|BLdpg#KnMh13)zy1H%l@|SH#HF4ARsDtk9VDq2xcDEc9sr$gBZ>e3 literal 0 HcmV?d00001 diff --git a/recipes/pillow/tests/webp_quadrants_lossless.webp b/recipes/pillow/tests/webp_quadrants_lossless.webp new file mode 100644 index 0000000000000000000000000000000000000000..3dce833bf51b61eb203478634f92e32e422e2c95 GIT binary patch literal 62 zcmWIYbaOLfU|yFfzz12zcC*uj>6NE+F#L{?;G%cohf7+h#tV R(^5S5@w&MDKm6wcI{*gk6pR1> literal 0 HcmV?d00001 diff --git a/recipes/pillow/tests/webp_quadrants_lossy.webp b/recipes/pillow/tests/webp_quadrants_lossy.webp new file mode 100644 index 0000000000000000000000000000000000000000..1e2052004e2cd7efa23a9af8891335d09aebb0d0 GIT binary patch literal 132 zcmV-~0DJ#ZNk&F|00012MM6+kP&goP0002+0sx%>DhL1w006)eC9nc?NB`~sZxDY; z21xCVln(#^{{5c5hcoks|MJpav;VKh|7JJ%UMD&5yKeu{MZ5R^{Gtc(CT%s}|MSkf m|3CeC%s>8eltSay?LYm=$o!xGy$gT(%0xeZ|EH#q^AG?Gv_Z%K literal 0 HcmV?d00001