Fix UnicodeEncodeError in caption drawing

The default PIL bitmap font only covers latin-1, so the em dash (U+2014)
in captions raised UnicodeEncodeError at runtime.

Add _load_font() which tries DejaVu Sans TTF from common system paths
(the fonts-dejavu package is already a listed dependency) and falls back
to the PIL default only when no TTF is found.  Add _draw_caption() which
wraps both the font load and the two-pass drop-shadow draw, and sanitises
the string for the bitmap fallback path so it never crashes.

Replace both raw draw.text caption blocks in composite_full_moon() and
render_phase_closeup() with _draw_caption().

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
This commit is contained in:
Claude
2026-05-01 18:07:53 +00:00
parent 09fda8d8af
commit b45ef3a791
+35 -4
View File
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageFilter, ImageFont
# Pillow ≥ 10 renamed resampling constants # Pillow ≥ 10 renamed resampling constants
try: try:
@@ -46,6 +46,39 @@ except AttributeError: # Pillow < 10
LANCZOS = Image.LANCZOS LANCZOS = Image.LANCZOS
BICUBIC = Image.BICUBIC BICUBIC = Image.BICUBIC
# Common DejaVu Sans paths across distros; first hit wins.
_DEJAVU_CANDIDATES = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/TTF/DejaVuSans.ttf',
'/Library/Fonts/DejaVuSans.ttf', # macOS homebrew
]
def _load_font(size: int = 18) -> ImageFont.ImageFont:
"""Return a Unicode-capable font at `size` pt, or the PIL default."""
for path in _DEJAVU_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except (OSError, IOError):
pass
return ImageFont.load_default()
def _draw_caption(draw: ImageDraw.ImageDraw, caption: str,
xy: tuple[int, int], output_w: int) -> None:
"""Draw a drop-shadow caption, sanitising characters the default font
cannot encode when no TTF is available."""
font = _load_font(max(14, output_w // 100))
# If we fell back to the bitmap default font it only handles latin-1; strip
# anything outside that range rather than crashing.
if not hasattr(font, 'path'):
caption = caption.encode('latin-1', errors='replace').decode('latin-1')
caption = caption.replace('\x3f', '-') # '?' placeholder → dash
x, y = xy
draw.text((x + 2, y + 2), caption, fill=(0, 0, 0), font=font)
draw.text((x, y), caption, fill=(220, 220, 220), font=font)
def _square_crop_to_disk(ref: Image.Image, threshold: int = 25) -> Image.Image: def _square_crop_to_disk(ref: Image.Image, threshold: int = 25) -> Image.Image:
"""Tight-crop a reference moon image to the disk's bounding square. """Tight-crop a reference moon image to the disk's bounding square.
@@ -200,9 +233,7 @@ def composite_full_moon(
# ── Caption ── # ── Caption ──
if caption: if caption:
draw = ImageDraw.Draw(bg) draw = ImageDraw.Draw(bg)
# Drop shadow for legibility _draw_caption(draw, caption, (22, output_size[1] - 48), output_size[0])
draw.text((24, output_size[1] - 44), caption, fill=(0, 0, 0))
draw.text((22, output_size[1] - 46), caption, fill=(220, 220, 220))
Path(out_path).parent.mkdir(parents=True, exist_ok=True) Path(out_path).parent.mkdir(parents=True, exist_ok=True)
bg.save(out_path, quality=92) bg.save(out_path, quality=92)