Render calendar emoji in full color
Build and push server image / build-and-push (push) Successful in 54s

Switched from the monochrome emoji font to color: NotoColorEmoji's
embedded CBDT bitmap glyphs, rasterized once at their native 109px
size and scaled to the target row height (unlike normal vector text,
color bitmap glyphs aren't stored at arbitrary sizes). Confirmed by
rendering an actual agenda row through the real quantizer that the
dithered-to-6-color result still reads clearly, not just muddy noise.

Falls back to the monochrome font if a deployment's Pillow/FreeType
wasn't built with embedded color bitmap support, so this degrades
instead of crashing or showing nothing.
This commit is contained in:
2026-07-23 04:25:10 -04:00
parent 67d99dd6c0
commit 33af5408fd
4 changed files with 109 additions and 41 deletions
+13 -10
View File
@@ -228,16 +228,19 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
a vendored monochrome font instead (`app/fonts/NotoEmoji.ttf`, Google's
Noto Emoji, OFL-1.1 -- license text alongside it at
`app/fonts/NotoEmoji-OFL.txt`), the one deliberate exception to this
project's usual "no new font/icon assets" default elsewhere in
calendar_render.py -- there's no way to hand-draw arbitrary emoji with
primitives the way the weather icons are. Monochrome rather than a
color emoji font on purpose: reliably rendering COLR/CBDT color glyphs
depends on how Pillow's FreeType was built, which isn't something this
project controls in every deployment environment, and solid black
reads cleanly against everything else this module draws in black.
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
usual "no new font/icon assets" default elsewhere in calendar_render.py
-- there's no way to hand-draw arbitrary emoji with primitives the way
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
fine through the panel's own Floyd-Steinberg dithering; a deployment
whose Pillow/FreeType wasn't built with embedded color bitmap support
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
instead of crashing or rendering nothing. Color glyphs are only stored
at one embedded bitmap size (109px), so they're rasterized once at
that size and scaled down to the target row height rather than drawn
directly like normal vector text.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
+96 -31
View File
@@ -144,21 +144,74 @@ def _fmt_time(dt: datetime) -> str:
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with TWO fonts: this one
# (monochrome Noto Emoji, OFL-1.1, vendored at app/fonts/NotoEmoji.ttf --
# see app/fonts/NotoEmoji-OFL.txt) for actual emoji runs, and the normal
# text font for everything else -- see _draw_mixed_line. Monochrome
# rather than a color emoji font on purpose: this is a 6-color dithered
# e-ink panel, and reliably rendering COLR/CBDT color glyphs depends on
# how Pillow's FreeType was built, which isn't something this project
# controls in every deployment environment. Solid black next to solid
# black text reads cleanly and matches everything else this module draws.
_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
# not supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
#
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
# recognizable (if slightly speckled) color rendering rather than a flat
# monochrome shape -- confirmed by actually rendering a test agenda row
# through the real quantizer, not just theorizing about it. Its one
# real quirk: CBDT stores glyphs at a single embedded bitmap size
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
# size and scaled down to the target row height, unlike normal vector
# text which draws directly at whatever size is asked for.
#
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
# whose Pillow/FreeType wasn't built with embedded color bitmap support
# -- confirmed working locally, but that's a build-time detail this
# project doesn't control everywhere it might run, so a rendering
# failure falls back instead of showing nothing/crashing.
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
_COLOR_EMOJI_NATIVE_SIZE = 109
@lru_cache(maxsize=1)
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
try:
return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
except Exception:
return None
@lru_cache(maxsize=None)
def _emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_EMOJI_FONT_PATH), size)
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)
@lru_cache(maxsize=512)
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
"""One emoji run (consecutive emoji collapse into a single run, see
_split_emoji_runs) as an RGBA image target_h tall, ready to
alpha-composite onto the canvas. Tries color first, falls back to
monochrome (rendered directly at target_h, since that font is
vector) if the color font failed to load or this Pillow/FreeType
build can't decode its embedded bitmaps. Cached -- the same emoji
recurs across a household's events, and rasterizing+scaling isn't
free."""
color_font = _color_emoji_font()
if color_font is not None:
try:
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
except Exception:
pass # this deployment's Pillow can't render embedded color bitmaps -- fall back
mono_font = _mono_emoji_font(target_h)
bbox = mono_font.getbbox(run_text)
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
glyph.paste((0, 0, 0, 255), (0, 0), mask)
return glyph
# Matches runs of actual emoji base characters (the standard Unicode
@@ -202,29 +255,43 @@ def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
text_font: ImageFont.ImageFont, emoji_font: ImageFont.ImageFont, max_width: int) -> None:
text_font: ImageFont.ImageFont, max_width: int) -> None:
"""Draws `text` left-to-right, switching between text_font (normal
characters) and emoji_font (actual emoji glyphs, per
_split_emoji_runs) so emoji visibly render instead of a tofu box.
Truncates with "..." once max_width is exceeded. Unlike
characters) and an emoji glyph image (actual emoji runs, per
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
tofu box. Truncates with "..." once max_width is exceeded -- unlike
_truncate_to_width this can't binary-search a single font's metrics
across mixed fonts, so it works run-by-run instead -- fine for the
short single-line strings this draws (event titles), not meant as a
general rich-text layout engine."""
across mixed fonts/images, so it works run-by-run instead (and can't
partially truncate an emoji run the way it can a text run -- one
that doesn't fit just isn't drawn). Fine for the short single-line
strings this draws (event titles), not meant as a general rich-text
layout engine."""
x, y = xy
cursor = x
# A little taller than text_font's own size so glyphs don't look
# cramped next to it; the -2 paste offset below roughly centers that
# against the surrounding text's row -- tuned by eye against a real
# rendered agenda row, not derived from font metrics.
emoji_h = text_font.size + 6
for run_text, is_emoji in _split_emoji_runs(text):
font = emoji_font if is_emoji else text_font
remaining = max_width - (cursor - x)
if remaining <= 0:
break
run_w = draw.textlength(run_text, font=font)
if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, font)
cursor += run_w
if is_emoji:
glyph = _emoji_glyph(run_text, emoji_h)
if glyph.width <= remaining:
img.paste(glyph, (round(cursor), y - 2), glyph)
cursor += glyph.width
else:
break
else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, font, remaining), font)
break
run_w = draw.textlength(run_text, font=text_font)
if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, text_font)
cursor += run_w
else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font)
break
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
@@ -429,7 +496,6 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
emoji_font = _emoji_font(body_font.size)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
for i, event in enumerate(day_events):
@@ -443,7 +509,7 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
draw_text(img, (text_x0 + 18, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font)
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
body_font, emoji_font, text_w - 18 - prefix_w)
body_font, text_w - 18 - prefix_w)
y += row_h
@@ -551,7 +617,6 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
row_h = chip_font.size + 10
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
emoji_font = _emoji_font(chip_font.size)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
@@ -559,13 +624,13 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, emoji_font, col_w - 20)
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else:
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
draw_text(img, (x0 + 16, y), prefix, chip_font)
prefix_w = draw.textlength(prefix, font=chip_font)
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
chip_font, emoji_font, col_w - 20 - prefix_w)
chip_font, col_w - 20 - prefix_w)
y += row_h
return img
Binary file not shown.