diff --git a/server/README.md b/server/README.md index 222f07d..df9092f 100644 --- a/server/README.md +++ b/server/README.md @@ -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). diff --git a/server/app/calendar_feed.py b/server/app/calendar_feed.py index 822e9d8..bd2d138 100644 --- a/server/app/calendar_feed.py +++ b/server/app/calendar_feed.py @@ -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 "" diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index e3b4eaa..f18a6b3 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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: diff --git a/server/app/fonts/NotoEmoji-OFL.txt b/server/app/fonts/NotoEmoji-OFL.txt new file mode 100644 index 0000000..d09d3d0 --- /dev/null +++ b/server/app/fonts/NotoEmoji-OFL.txt @@ -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. diff --git a/server/app/fonts/NotoEmoji.ttf b/server/app/fonts/NotoEmoji.ttf new file mode 100644 index 0000000..c2c26ab Binary files /dev/null and b/server/app/fonts/NotoEmoji.ttf differ