diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f04f8c12..1d25cb6a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ## [Unreleased] +### Fixed + +- **OG cards render without MonoLisa's italic swashes in production** — the live + `api.anyplot.ai/og/home.png` is pixel-identical to a render forced onto Pillow's + BASIC layout engine, which means the deployed container's Pillow has no libraqm: + the `ss02` stylistic set on `— any library.` is dropped and no text is kerned. The + helper that draws feature runs swallowed the exception, so the degradation was + invisible in the logs and the endpoint kept returning a valid 200 PNG. The fallback + now warns once per process, the API logs the shaping capability at startup, and + `api/Dockerfile` fails the build outright when Pillow lands without libraqm. (#10813) + ### Changed - **`anyplot-app` scales to zero** — the frontend service ran a permanently warm diff --git a/api/Dockerfile b/api/Dockerfile index 008cadf797..80acf32103 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,6 +22,15 @@ RUN pip install --no-cache-dir uv && \ uv venv && \ uv sync --frozen +# Fail the build if Pillow lands without libraqm. Without text shaping Pillow +# falls back to its BASIC layout engine: the OG cards lose MonoLisa's italic +# `ss02` swashes and all kerning, and nothing in the response signals it — the +# only symptom is an off-brand PNG. Better a red build than a silent regression. +RUN .venv/bin/python -c "\ +from PIL import features; \ +assert features.check('raqm'), \ +'Pillow was installed without libraqm — OG image text shaping would be disabled. Use a Pillow wheel that bundles libraqm (or install libraqm before building it from source).'" + # Copy application code COPY api/ ./api/ COPY core/ ./core/ diff --git a/api/main.py b/api/main.py index 99900a922f..a6ba3c335a 100644 --- a/api/main.py +++ b/api/main.py @@ -49,6 +49,7 @@ from core.config import settings # noqa: E402 from core.constants import LANGUAGES_METADATA, LIBRARIES_METADATA # noqa: E402 from core.database import close_db, init_db, is_db_configured # noqa: E402 +from core.images import has_text_shaping # noqa: E402 # Configure logging @@ -109,6 +110,14 @@ async def lifespan(app: FastAPI): """Manage application lifecycle.""" logger.info("Starting anyplot API...") + # Text shaping decides whether the OG cards carry MonoLisa's italic swashes + # (`ss02`) and kerning at all — log it once so a degraded image is visible + # in Cloud Run logs instead of only in a pixel diff of the served PNG. + if has_text_shaping(): + logger.info("Text shaping (libraqm): available") + else: + logger.error("Text shaping (libraqm): MISSING — OG images render unkerned and without MonoLisa swashes") + # Initialize database connection if is_db_configured(): try: diff --git a/core/images.py b/core/images.py index 214b0b040a..a9f8dff5b1 100644 --- a/core/images.py +++ b/core/images.py @@ -21,6 +21,7 @@ from pathlib import Path from PIL import Image, ImageDraw, ImageFont +from PIL import features as pil_features # anyplot categorical palette — "imprint" (v3 hybrid-v3 ordering). # Defined as a separate module so the project's named-API (palette.green, @@ -937,20 +938,68 @@ def create_branded_header(width: int = OG_WIDTH, height: int = HEADER_HEIGHT) -> return header +def has_text_shaping() -> bool: + """Whether Pillow can shape text (libraqm/HarfBuzz present). + + Without it Pillow silently uses its BASIC layout engine: OpenType features + such as MonoLisa's `ss02` italic swashes are unavailable AND no text is + kerned, so every OG card renders subtly off-brand. Public so the API can + report the capability at startup instead of discovering it from a diff of + two PNGs. + """ + try: + return bool(pil_features.check("raqm")) + except Exception: # pragma: no cover — defensive: older/patched Pillow builds + return False + + +def _warn_missing_shaping(features: list[str]) -> None: + """Log the missing-libraqm degradation once per process. + + Once, not per text run: a degraded worker draws the same warning on every + request and would otherwise bury the rest of the log. + """ + global _SHAPING_WARNED + if _SHAPING_WARNED: + return + _SHAPING_WARNED = True + logger.warning( + "Pillow has no libraqm/HarfBuzz support — OpenType features %s are dropped and text is rendered " + "unkerned (BASIC layout). OG cards will not match the brand rendering; rebuild the image with a " + "Pillow wheel that bundles libraqm.", + features, + ) + + +_SHAPING_WARNED = False + + def _draw_text_with_features( draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str, *, font, fill, features: list[str] | None = None ) -> None: """Draw text with optional OpenType features (e.g. `ss02` for MonoLisa Italic swashes). Falls back to plain `draw.text(...)` when Pillow is built without libraqm - (no feature support) or when the font doesn't supply the requested feature. + (no feature support) or when the font doesn't supply the requested feature — + but says so in the log rather than degrading silently, which is how the + production OG cards lost their italic swashes unnoticed (issue: OG image + served without MonoLisa `ss02`). """ + if not features: + draw.text(xy, text, font=font, fill=fill) + return + + if not has_text_shaping(): + _warn_missing_shaping(features) + draw.text(xy, text, font=font, fill=fill) + return + try: - if features: - draw.text(xy, text, font=font, fill=fill, features=features) - else: - draw.text(xy, text, font=font, fill=fill) - except Exception: + draw.text(xy, text, font=font, fill=fill, features=features) + except (KeyError, ValueError, OSError) as exc: + # KeyError is what Pillow raises when features are unsupported; the + # others cover a font that lacks the requested set. + logger.warning("OpenType features %s not applied to %r: %s", features, text, exc) draw.text(xy, text, font=font, fill=fill) diff --git a/docs/reference/seo.md b/docs/reference/seo.md index 9436843497..51ae5f04b4 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -324,9 +324,32 @@ Layout: Uses **MonoLisa** variable font (commercial, not in repo): - Downloaded from GCS: `gs://anyplot-static/fonts/MonoLisaVariableNormal.ttf` +- Italic variant (`MonoLisaVariableItalic.ttf`) for the tagline swashes - Cached locally in `/tmp/anyplot-fonts/` - Fallback: DejaVuSansMono-Bold +### Text shaping (libraqm) + +The home card draws its `— any library.` line with the OpenType stylistic set +`ss02`, which turns MonoLisa Italic into its script forms. Pillow can only apply +OpenType features and kerning when it is built with libraqm; without it, Pillow +silently switches to its BASIC layout engine and the card renders unkerned and +without swashes — the response stays a valid 200 PNG, so nothing else signals +the degradation. + +Two guards keep that visible: + +- `api/Dockerfile` fails the build when the installed Pillow lacks libraqm. +- The API logs the capability at startup (`Text shaping (libraqm): available`, + or an error line when it is missing), and `core.images` logs a warning the + first time a feature run is dropped. + +To check an environment yourself, run: + +```bash +uv run python -c "from PIL import features; print(features.check('raqm'))" +``` + ## Robots.txt ### Frontend (anyplot.ai) diff --git a/tests/unit/core/test_images.py b/tests/unit/core/test_images.py index 998743f091..1d8555f42f 100644 --- a/tests/unit/core/test_images.py +++ b/tests/unit/core/test_images.py @@ -903,6 +903,132 @@ def test_creates_cache_dir_on_download(self, tmp_path: Path) -> None: assert cache_dir.exists() +class TestTextShaping: + """Tests for libraqm text shaping detection and the feature-drawing helper.""" + + @pytest.fixture(autouse=True) + def reset_warn_flag(self): + """Reset the once-per-process warning latch so each test can observe it.""" + import core.images + + core.images._SHAPING_WARNED = False + yield + core.images._SHAPING_WARNED = False + + def test_environment_has_text_shaping(self) -> None: + """The rendering environment must have libraqm. + + Without it every OG card silently loses MonoLisa's italic `ss02` + swashes and all kerning — exactly the production regression this + module guards against. A red test here means the Pillow wheel (or the + container image) lost libraqm, not that the test is wrong. + """ + from core.images import has_text_shaping + + assert has_text_shaping(), "Pillow has no libraqm support — OG images would render unkerned" + + def test_draws_with_features_when_shaping_available(self) -> None: + """Should pass the requested OpenType features through to Pillow.""" + from unittest.mock import MagicMock, patch + + import core.images + + draw = MagicMock() + with patch("core.images.has_text_shaping", return_value=True): + core.images._draw_text_with_features( + draw, (0, 0), "— any library.", font=None, fill="#000000", features=["ss02"] + ) + + assert draw.text.call_args.kwargs["features"] == ["ss02"] + + def test_warns_once_when_shaping_unavailable(self, caplog) -> None: + """Should fall back to plain text AND log the degradation exactly once.""" + import logging + from unittest.mock import MagicMock, patch + + import core.images + + draw = MagicMock() + with patch("core.images.has_text_shaping", return_value=False), caplog.at_level(logging.WARNING): + core.images._draw_text_with_features( + draw, (0, 0), "— any library.", font=None, fill="#000000", features=["ss02"] + ) + core.images._draw_text_with_features( + draw, (0, 40), "— any library.", font=None, fill="#000000", features=["ss02"] + ) + + # Features dropped, text still drawn + assert "features" not in draw.text.call_args.kwargs + assert draw.text.call_count == 2 + # Warned once, not per text run + warnings = [r for r in caplog.records if "libraqm" in r.getMessage()] + assert len(warnings) == 1 + + def test_warns_when_font_lacks_feature(self, caplog) -> None: + """Should fall back and log when Pillow rejects the feature for this font.""" + import logging + from unittest.mock import MagicMock, patch + + import core.images + + draw = MagicMock() + draw.text.side_effect = [KeyError("unsupported"), None] + with patch("core.images.has_text_shaping", return_value=True), caplog.at_level(logging.WARNING): + core.images._draw_text_with_features( + draw, (0, 0), "— any library.", font=None, fill="#000000", features=["ss02"] + ) + + assert draw.text.call_count == 2 # feature attempt + plain fallback + assert any("not applied" in r.getMessage() for r in caplog.records) + + def test_no_features_draws_plain_text(self) -> None: + """Should not touch the features kwarg when no features are requested.""" + from unittest.mock import MagicMock + + import core.images + + draw = MagicMock() + core.images._draw_text_with_features(draw, (0, 0), "plain", font=None, fill="#000000") + + assert "features" not in draw.text.call_args.kwargs + + def test_home_og_image_renders_swashes(self) -> None: + """The home card must differ with and without `ss02` — proof the swashes land. + + Guards the production regression directly: when the feature is dropped + the card still renders, so only a pixel comparison catches it. Skipped + where MonoLisa is not cached locally (no GCS access): the DejaVu + fallback has no `ss02`, so both renders would legitimately match. + """ + from io import BytesIO + from unittest.mock import patch + + from PIL import ImageChops + + from core.images import FONT_CACHE_DIR, create_home_og_image + + if not (FONT_CACHE_DIR / "MonoLisaVariableItalic.ttf").exists(): + pytest.skip("MonoLisa italic not cached locally — swash rendering cannot be verified") + + with_features = create_home_og_image(theme="light") + with patch("core.images.has_text_shaping", return_value=False): + without_features = create_home_og_image(theme="light") + + assert isinstance(with_features, bytes) + assert isinstance(without_features, bytes) + + # Compare pixels, not encoded bytes: a PNG encoder change would make a + # byte comparison pass while the swashes are gone. + swashed = Image.open(BytesIO(with_features)).convert("RGB") + plain = Image.open(BytesIO(without_features)).convert("RGB") + diff_box = ImageChops.difference(swashed, plain).getbbox() + + assert diff_box is not None, "`ss02` changed no pixel — the swashes are not being applied" + # The tagline is the only line drawn with features, so the diff must sit + # in the headline block and nowhere else (eyebrow row ends at y≈100). + assert diff_box[1] > 100, f"unexpected diff outside the tagline block: {diff_box}" + + class TestBrandingCLI: """Tests for branding CLI commands."""