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
|
(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).
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user