Actually render emoji in calendar event titles; stack duplicate shared events
Build and push server image / build-and-push (push) Successful in 53s

The earlier fix stripped emoji instead of rendering them, which wasn't
what was asked for. Event titles now draw with two fonts: the usual
default font for text, and a vendored monochrome emoji font (Noto
Emoji, OFL-1.1) for actual emoji runs, so they show up as real glyphs
instead of a tofu box or nothing at all. Monochrome rather than color,
since reliably rendering COLR/CBDT color glyphs depends on how Pillow's
FreeType was built -- not something to depend on across deployments.

Also: events sharing the exact same title and time across different
calendars (e.g. a shared family event synced onto more than one
person's calendar) now collapse into one row instead of showing twice,
with a color bar split between every contributing calendar so it's
still clear whose event it is.
This commit is contained in:
2026-07-22 23:13:34 -04:00
parent 7fc262f9c3
commit 67d99dd6c0
5 changed files with 276 additions and 49 deletions
+14
View File
@@ -224,6 +224,20 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(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.
- 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).
+20 -4
View File
@@ -115,8 +115,18 @@ def merge_events(
fetch_summary is "" when every source succeeded, else "N of M
calendars unavailable" (never *which* source -- naming whose feed is
down to everyone who looks at a shared household display is a bigger
overshare than the outage itself)."""
overshare than the outage itself).
Events sharing the exact same (summary, start, end, all_day) across
different calendars -- e.g. a shared family event synced onto more
than one person's calendar -- collapse into one entry rather than
showing as duplicate rows. Every merged event carries a "sources"
list ([{"owner_display_name", "color_index"}, ...], length 1 for an
ordinary non-duplicated event) that calendar_render.py draws a
color indicator per entry of, so a collapsed event still visibly
shows every calendar it came from."""
merged: list[dict] = []
by_key: dict[tuple, dict] = {}
failures = 0
for source in sources:
try:
@@ -130,9 +140,15 @@ def merge_events(
failures += 1
continue
for event in events:
event["owner_display_name"] = source.owner_display_name
event["color_index"] = source.color_index
merged.append(event)
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
key = (event["summary"], event["start"], event["end"], event["all_day"])
existing = by_key.get(key)
if existing is None:
event["sources"] = [source_entry]
by_key[key] = event
merged.append(event)
else:
existing["sources"].append(source_entry)
merged.sort(key=lambda e: e["start"])
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
+149 -45
View File
@@ -4,7 +4,10 @@ precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
_quantize/_transpose_and_pack every other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "owner_display_name"}.
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/_draw_color_bar below.
"""
from __future__ import annotations
@@ -13,6 +16,8 @@ import calendar as calendar_module
import io
import re
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
@@ -50,22 +55,54 @@ RULE = (0, 0, 0)
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _event_color(event: dict, owners_seen: list[str], palette_rgb: list | None) -> tuple[int, int, int]:
"""A specific calendar's manually pinned color (event["color_index"],
set from models.FrameCalendar.color_index -- see
routers/api_frames.py's api_calendar_color) resolved against
whichever palette this frame actually renders with, so a pinned
"Blue" still looks like this frame's blue even if its Advanced
configuration has retuned the panel's RGB values. Falls back to the
old auto-cycle-by-owner-name when a calendar has no color pinned."""
color_index = event.get("color_index")
if color_index is not None:
palette = palette_rgb or DEFAULT_PALETTE_RGB
return tuple(palette[color_index])
owner_display_name = event["owner_display_name"]
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
"""One color per contributing calendar (event["sources"] -- see
calendar_feed.merge_events, which collapses events sharing the exact
same title/time across different calendars into one entry with
several sources, e.g. a shared family event synced onto more than
one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_frames.py's
api_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
blue; a source with no color pinned falls back to the old
auto-cycle-by-owner-name behavior. owners_seen is shared across every
event/source in a render so that cycle stays consistent view-wide."""
sources = event.get("sources") or [
{"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
]
colors = []
for source in sources:
color_index = source.get("color_index")
if color_index is not None:
palette = palette_rgb or DEFAULT_PALETTE_RGB
colors.append(tuple(palette[color_index]))
continue
owner_display_name = source.get("owner_display_name")
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
return colors
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int) -> None:
"""One rounded bar for a single-source event, or that same overall
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
_event_colors/calendar_feed.merge_events). Splitting rather than
e.g. concentric rings keeps every color equally "thick and bold" at
a glance, the same design goal a single pinned color already has."""
if len(colors) == 1:
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
@@ -103,16 +140,30 @@ def _fmt_time(dt: datetime) -> str:
return text if text else "12:00 AM"
# ImageFont.load_default() (used everywhere in this module -- see the
# module docstring) has no emoji glyphs. PIL/FreeType don't just skip an
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
# filled rectangle), which reads as a rendering glitch rather than "emoji
# not supported" on an actual panel. Stripped before drawing rather than
# left to render as tofu. Covers the standard Unicode emoji blocks (plus
# skin-tone modifiers, the zero-width joiner used to combine them into
# one glyph, and the variation selector that forces emoji presentation)
# -- these block ranges are stable even as new individual emoji get
# added within them, so this doesn't need updating as emoji sets grow.
# ImageFont.load_default() (used for everything else in this module --
# 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"
@lru_cache(maxsize=None)
def _emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_EMOJI_FONT_PATH), size)
# Matches runs of actual emoji base characters (the standard Unicode
# emoji blocks -- stable ranges even as new individual emoji get added
# within them, so this doesn't need updating as emoji sets grow).
_EMOJI_PATTERN = re.compile(
"["
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
@@ -121,20 +172,59 @@ _EMOJI_PATTERN = re.compile(
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
"\U0001F3FB-\U0001F3FF" # skin-tone modifiers
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
"\U00002700-\U000027BF" # dingbats (✂✈✉ etc.)
"\U0000FE0F" # variation selector-16 (emoji presentation)
"\U0000200D" # zero-width joiner
"]+"
)
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
# Codepoints with no meaningful standalone glyph once color/ligature
# context is dropped: skin-tone modifiers (this is monochrome -- no
# color to modify), the variation selector that just requests emoji
# presentation, and the zero-width joiner used to fuse multiple emoji
# into one combined glyph. That fusion (e.g. the "family" emoji from
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
# build -- not something to depend on. Stripping the ZWJ instead means a
# ZWJ sequence just draws as its individual base glyphs side by side
# (four separate people instead of one family glyph) -- a real fallback,
# not a crash or tofu.
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")
def _strip_emoji(text: str) -> str:
"""Removes emoji (see _EMOJI_PATTERN above) and collapses whatever
whitespace that leaves behind -- "\U0001F389 Birthday party" becomes
"Birthday party", not " Birthday party"."""
return re.sub(r"\s+", " ", _EMOJI_PATTERN.sub("", text)).strip()
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
plain-text characters into the other."""
cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]
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:
"""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
_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."""
x, y = xy
cursor = x
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
else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, font, remaining), font)
break
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
@@ -339,17 +429,21 @@ 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):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
break
color = _event_color(event, owners_seen, palette_rgb)
draw.rounded_rectangle([text_x0, y + 2, text_x0 + 10, y + row_h - 7], radius=3, fill=color)
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {_strip_emoji(event['summary'])}"
draw_text(img, (text_x0 + 18, y), _truncate_to_width(draw, line, body_font, text_w - 18), body_font)
prefix = f"{time_str} "
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)
y += row_h
@@ -457,15 +551,21 @@ 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)
break
color = _event_color(event, owners_seen, palette_rgb)
draw.rounded_rectangle([x0 + 4, y + 1, x0 + 11, y + row_h - 5], radius=2, fill=color)
summary = _strip_emoji(event["summary"])
text = summary if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {summary}"
draw_text(img, (x0 + 16, y), _truncate_to_width(draw, text, chip_font, col_w - 20), chip_font)
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)
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)
y += row_h
return img
@@ -516,7 +616,11 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
dot_x = x0 + 8
dot_y = y0 + row_h - dot_r * 2 - 6
for i, event in enumerate(day_events[:4]):
event_color = _event_color(event, owners_seen, palette_rgb)
# First contributing calendar's color only, even for a
# deduplicated shared event -- month view is density
# dots, not a place to also show which calendars a
# shared event came from (see _event_colors).
event_color = _event_colors(event, owners_seen, palette_rgb)[0]
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
+93
View File
@@ -0,0 +1,93 @@
Copyright 2013 Google LLC
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.