Render calendar emoji in full color
Build and push server image / build-and-push (push) Successful in 54s
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:
+13
-10
@@ -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).
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user