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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
9 changes: 9 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
61 changes: 55 additions & 6 deletions core/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
23 changes: 23 additions & 0 deletions docs/reference/seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
126 changes: 126 additions & 0 deletions tests/unit/core/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading