From 61769b6f36ad5907c24cece6e8de2018d547f1f2 Mon Sep 17 00:00:00 2001 From: Amit Moryossef Date: Wed, 7 Oct 2026 15:00:57 -0400 Subject: [PATCH 1/2] fix: load Pillow's FreeType before Pango to stop symbol clash (#16) font_configurator imports gi.repository.Pango at module level, which GI loads with RTLD_GLOBAL. Pillow's _imagingft was loaded afterwards, so its FT_*/hb_* calls (and even its bundled FreeType's internal calls) bound to the system FreeType/HarfBuzz instead of Pillow's bundled copies. Pillow's FreeType 2.14 faces were then driven by FreeType 2.13 functions - undefined behavior that showed up as garbage SignWriting glyph bboxes on CI. Importing PIL.ImageFont first makes Pillow bind to its own libraries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fy1wuWdqcKuebgj3St8dME --- pixel_renderer/__init__.py | 6 ++++++ tests/pixel_renderer/test_freetype_isolation.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/pixel_renderer/test_freetype_isolation.py diff --git a/pixel_renderer/__init__.py b/pixel_renderer/__init__.py index dfe9ce4..4302c3e 100644 --- a/pixel_renderer/__init__.py +++ b/pixel_renderer/__init__.py @@ -1,2 +1,8 @@ +# Load Pillow's FreeType bindings before anything imports gi.repository.Pango. +# Pango is loaded with RTLD_GLOBAL, so if it comes first, Pillow's bundled FreeType/HarfBuzz +# resolve their symbols against the system libraries instead, corrupting SignWriting rendering. +# See https://github.com/sign/pixel-renderer/issues/16 +from PIL import ImageFont # noqa: F401, I001 + from pixel_renderer.processor import PixelRendererProcessor # noqa: F401 from pixel_renderer.renderer import * # noqa: F403 diff --git a/tests/pixel_renderer/test_freetype_isolation.py b/tests/pixel_renderer/test_freetype_isolation.py new file mode 100644 index 0000000..b629235 --- /dev/null +++ b/tests/pixel_renderer/test_freetype_isolation.py @@ -0,0 +1,14 @@ +import subprocess +import sys + +FREETYPE_VERSION = "from PIL import ImageFont, features; print(features.version('freetype2'))" + + +def freetype_version_after(setup: str) -> str: + code = f"{setup}\n{FREETYPE_VERSION}" + return subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout.strip() + + +def test_pillow_keeps_its_own_freetype_after_importing_pixel_renderer(): + """Pillow must not bind to the system FreeType loaded by Pango (issue #16).""" + assert freetype_version_after("import pixel_renderer") == freetype_version_after("") From 401210bcc354c0c1c03704f62ea13a7684848742 Mon Sep 17 00:00:00 2001 From: Amit Moryossef Date: Wed, 7 Oct 2026 15:03:43 -0400 Subject: [PATCH 2/2] fix: guard every Pango import, not just the pixel_renderer package CI still failed when tests imported font_configurator directly (before pixel_renderer), loading Pango ahead of Pillow. Import PIL.ImageFont right before gi.repository in both modules that load Pango. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fy1wuWdqcKuebgj3St8dME --- font_configurator/font_configurator.py | 4 ++++ pixel_renderer/__init__.py | 6 ------ pixel_renderer/renderer.py | 4 ++++ tests/pixel_renderer/test_freetype_isolation.py | 7 +++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/font_configurator/font_configurator.py b/font_configurator/font_configurator.py index 22bd343..9c4c262 100644 --- a/font_configurator/font_configurator.py +++ b/font_configurator/font_configurator.py @@ -22,6 +22,10 @@ gi.require_version("Pango", "1.0") gi.require_version("PangoCairo", "1.0") +# Pillow's FreeType must be loaded before Pango: GI loads Pango with RTLD_GLOBAL, after which Pillow's +# bundled FreeType/HarfBuzz would bind to the system libraries and corrupt SignWriting rendering. +# See https://github.com/sign/pixel-renderer/issues/16 +from PIL import ImageFont # noqa: E402, F401, I001 from gi.repository import Pango, PangoCairo # type: ignore # noqa: E402, F401 FONTCONFIG_CACHE_DIR = pathlib.Path(user_cache_dir("font_configurator")) diff --git a/pixel_renderer/__init__.py b/pixel_renderer/__init__.py index 4302c3e..dfe9ce4 100644 --- a/pixel_renderer/__init__.py +++ b/pixel_renderer/__init__.py @@ -1,8 +1,2 @@ -# Load Pillow's FreeType bindings before anything imports gi.repository.Pango. -# Pango is loaded with RTLD_GLOBAL, so if it comes first, Pillow's bundled FreeType/HarfBuzz -# resolve their symbols against the system libraries instead, corrupting SignWriting rendering. -# See https://github.com/sign/pixel-renderer/issues/16 -from PIL import ImageFont # noqa: F401, I001 - from pixel_renderer.processor import PixelRendererProcessor # noqa: F401 from pixel_renderer.renderer import * # noqa: F403 diff --git a/pixel_renderer/renderer.py b/pixel_renderer/renderer.py index 868fa39..25c7a84 100644 --- a/pixel_renderer/renderer.py +++ b/pixel_renderer/renderer.py @@ -11,6 +11,10 @@ gi.require_version("Pango", "1.0") gi.require_version("PangoCairo", "1.0") gi.require_foreign("cairo") +# Pillow's FreeType must be loaded before Pango: GI loads Pango with RTLD_GLOBAL, after which Pillow's +# bundled FreeType/HarfBuzz would bind to the system libraries and corrupt SignWriting rendering. +# See https://github.com/sign/pixel-renderer/issues/16 +from PIL import ImageFont # noqa: E402, F401, I001 from gi.repository import Pango, PangoCairo # noqa: E402 # Reusable measurement context - avoids creating new surface/context/layout per call diff --git a/tests/pixel_renderer/test_freetype_isolation.py b/tests/pixel_renderer/test_freetype_isolation.py index b629235..b7d37e0 100644 --- a/tests/pixel_renderer/test_freetype_isolation.py +++ b/tests/pixel_renderer/test_freetype_isolation.py @@ -1,6 +1,8 @@ import subprocess import sys +import pytest + FREETYPE_VERSION = "from PIL import ImageFont, features; print(features.version('freetype2'))" @@ -9,6 +11,7 @@ def freetype_version_after(setup: str) -> str: return subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout.strip() -def test_pillow_keeps_its_own_freetype_after_importing_pixel_renderer(): +@pytest.mark.parametrize("module", ["pixel_renderer", "pixel_renderer.renderer", "font_configurator.font_configurator"]) +def test_pillow_keeps_its_own_freetype(module): """Pillow must not bind to the system FreeType loaded by Pango (issue #16).""" - assert freetype_version_after("import pixel_renderer") == freetype_version_after("") + assert freetype_version_after(f"import {module}") == freetype_version_after("")