diff --git a/server/README.md b/server/README.md index df9092f..33f982b 100644 --- a/server/README.md +++ b/server/README.md @@ -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). diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index f18a6b3..d20d8c9 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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 diff --git a/server/app/fonts/NotoColorEmoji.ttf b/server/app/fonts/NotoColorEmoji.ttf new file mode 100644 index 0000000..943741d Binary files /dev/null and b/server/app/fonts/NotoColorEmoji.ttf differ diff --git a/server/app/fonts/NotoEmoji-OFL.txt b/server/app/fonts/OFL.txt similarity index 100% rename from server/app/fonts/NotoEmoji-OFL.txt rename to server/app/fonts/OFL.txt