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 (used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
skipping the codepoint. `app/calendar_render.py` draws emoji runs with skipping the codepoint. `app/calendar_render.py` draws emoji runs with
a vendored monochrome font instead (`app/fonts/NotoEmoji.ttf`, Google's a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
Noto Emoji, OFL-1.1 -- license text alongside it at at `app/fonts/OFL.txt`), the one deliberate exception to this project's
`app/fonts/NotoEmoji-OFL.txt`), the one deliberate exception to this usual "no new font/icon assets" default elsewhere in calendar_render.py
project's usual "no new font/icon assets" default elsewhere in -- there's no way to hand-draw arbitrary emoji with primitives the way
calendar_render.py -- there's no way to hand-draw arbitrary emoji with the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
primitives the way the weather icons are. Monochrome rather than a embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
color emoji font on purpose: reliably rendering COLR/CBDT color glyphs fine through the panel's own Floyd-Steinberg dithering; a deployment
depends on how Pillow's FreeType was built, which isn't something this whose Pillow/FreeType wasn't built with embedded color bitmap support
project controls in every deployment environment, and solid black falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
reads cleanly against everything else this module draws in black. 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` - The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values (`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel). (Waveshare doesn't publish exact color primaries for this panel).
+93 -28
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 # see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a # skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji # visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with TWO fonts: this one # not supported". So event titles get drawn with two fonts: the normal
# (monochrome Noto Emoji, OFL-1.1, vendored at app/fonts/NotoEmoji.ttf -- # text font for everything else, and one of these for actual emoji runs
# see app/fonts/NotoEmoji-OFL.txt) for actual emoji runs, and the normal # (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# text font for everything else -- see _draw_mixed_line. Monochrome # vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
# 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 # Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
# how Pillow's FreeType was built, which isn't something this project # glyphs, which the panel's own Floyd-Steinberg dithering turns into a
# controls in every deployment environment. Solid black next to solid # recognizable (if slightly speckled) color rendering rather than a flat
# black text reads cleanly and matches everything else this module draws. # monochrome shape -- confirmed by actually rendering a test agenda row
_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf" # 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) @lru_cache(maxsize=None)
def _emoji_font(size: int) -> ImageFont.FreeTypeFont: def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_EMOJI_FONT_PATH), size) 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 # Matches runs of actual emoji base characters (the standard Unicode
@@ -202,28 +255,42 @@ 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, 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 """Draws `text` left-to-right, switching between text_font (normal
characters) and emoji_font (actual emoji glyphs, per characters) and an emoji glyph image (actual emoji runs, per
_split_emoji_runs) so emoji visibly render instead of a tofu box. _split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
Truncates with "..." once max_width is exceeded. Unlike tofu box. Truncates with "..." once max_width is exceeded -- unlike
_truncate_to_width this can't binary-search a single font's metrics _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 across mixed fonts/images, so it works run-by-run instead (and can't
short single-line strings this draws (event titles), not meant as a partially truncate an emoji run the way it can a text run -- one
general rich-text layout engine.""" 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 x, y = xy
cursor = x 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): for run_text, is_emoji in _split_emoji_runs(text):
font = emoji_font if is_emoji else text_font
remaining = max_width - (cursor - x) remaining = max_width - (cursor - x)
if remaining <= 0: if remaining <= 0:
break break
run_w = draw.textlength(run_text, font=font) 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:
run_w = draw.textlength(run_text, font=text_font)
if run_w <= remaining: if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, font) draw_text(img, (round(cursor), y), run_text, text_font)
cursor += run_w cursor += run_w
else: else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, font, remaining), font) draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font)
break break
@@ -429,7 +496,6 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
row_h = body_font.size + 14 row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h) max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
emoji_font = _emoji_font(body_font.size)
if not day_events: if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED) draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
for i, event in enumerate(day_events): 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) draw_text(img, (text_x0 + 18, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font) prefix_w = draw.textlength(prefix, font=body_font)
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"], _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 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 row_h = chip_font.size + 10
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h) max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
emoji_font = _emoji_font(chip_font.size)
for i, event in enumerate(day_events): for i, event in enumerate(day_events):
if i >= max_rows: if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED) 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) 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) _draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]: 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: else:
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} " prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
draw_text(img, (x0 + 16, y), prefix, chip_font) draw_text(img, (x0 + 16, y), prefix, chip_font)
prefix_w = draw.textlength(prefix, font=chip_font) prefix_w = draw.textlength(prefix, font=chip_font)
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"], _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 y += row_h
return img return img
Binary file not shown.