Actually render emoji in calendar event titles; stack duplicate shared events
Build and push server image / build-and-push (push) Successful in 53s
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:
@@ -224,6 +224,20 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
explicit, informed call by the project owner, not a default -- anyone
|
explicit, informed call by the project owner, not a default -- anyone
|
||||||
redistributing this project (vs. just self-hosting it) should
|
redistributing this project (vs. just self-hosting it) should
|
||||||
re-evaluate that tradeoff for their own situation before doing so.
|
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`
|
- 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).
|
||||||
|
|||||||
@@ -115,8 +115,18 @@ def merge_events(
|
|||||||
fetch_summary is "" when every source succeeded, else "N of M
|
fetch_summary is "" when every source succeeded, else "N of M
|
||||||
calendars unavailable" (never *which* source -- naming whose feed is
|
calendars unavailable" (never *which* source -- naming whose feed is
|
||||||
down to everyone who looks at a shared household display is a bigger
|
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] = []
|
merged: list[dict] = []
|
||||||
|
by_key: dict[tuple, dict] = {}
|
||||||
failures = 0
|
failures = 0
|
||||||
for source in sources:
|
for source in sources:
|
||||||
try:
|
try:
|
||||||
@@ -130,9 +140,15 @@ def merge_events(
|
|||||||
failures += 1
|
failures += 1
|
||||||
continue
|
continue
|
||||||
for event in events:
|
for event in events:
|
||||||
event["owner_display_name"] = source.owner_display_name
|
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
|
||||||
event["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)
|
merged.append(event)
|
||||||
|
else:
|
||||||
|
existing["sources"].append(source_entry)
|
||||||
|
|
||||||
merged.sort(key=lambda e: e["start"])
|
merged.sort(key=lambda e: e["start"])
|
||||||
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
|
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
|
||||||
|
|||||||
+145
-41
@@ -4,7 +4,10 @@ precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
|
|||||||
_quantize/_transpose_and_pack every other renderer ends on.
|
_quantize/_transpose_and_pack every other renderer ends on.
|
||||||
|
|
||||||
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
|
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
|
from __future__ import annotations
|
||||||
@@ -13,6 +16,8 @@ import calendar as calendar_module
|
|||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
@@ -50,22 +55,54 @@ RULE = (0, 0, 0)
|
|||||||
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
|
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
|
||||||
|
|
||||||
|
|
||||||
def _event_color(event: dict, owners_seen: list[str], palette_rgb: list | None) -> tuple[int, int, int]:
|
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
|
||||||
"""A specific calendar's manually pinned color (event["color_index"],
|
"""One color per contributing calendar (event["sources"] -- see
|
||||||
set from models.FrameCalendar.color_index -- see
|
calendar_feed.merge_events, which collapses events sharing the exact
|
||||||
routers/api_frames.py's api_calendar_color) resolved against
|
same title/time across different calendars into one entry with
|
||||||
whichever palette this frame actually renders with, so a pinned
|
several sources, e.g. a shared family event synced onto more than
|
||||||
"Blue" still looks like this frame's blue even if its Advanced
|
one person's calendar). Usually just one color; more than one is
|
||||||
configuration has retuned the panel's RGB values. Falls back to the
|
what tells the "same event, more than one calendar" case apart from
|
||||||
old auto-cycle-by-owner-name when a calendar has no color pinned."""
|
an ordinary single-calendar event at render time -- see
|
||||||
color_index = event.get("color_index")
|
_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:
|
if color_index is not None:
|
||||||
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
||||||
return tuple(palette[color_index])
|
colors.append(tuple(palette[color_index]))
|
||||||
owner_display_name = event["owner_display_name"]
|
continue
|
||||||
|
owner_display_name = source.get("owner_display_name")
|
||||||
if owner_display_name not in owners_seen:
|
if owner_display_name not in owners_seen:
|
||||||
owners_seen.append(owner_display_name)
|
owners_seen.append(owner_display_name)
|
||||||
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
|
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:
|
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"
|
return text if text else "12:00 AM"
|
||||||
|
|
||||||
|
|
||||||
# ImageFont.load_default() (used everywhere in this module -- see the
|
# ImageFont.load_default() (used for everything else in this module --
|
||||||
# module docstring) has no emoji glyphs. PIL/FreeType don't just skip an
|
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
|
||||||
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
|
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
|
||||||
# filled rectangle), which reads as a rendering glitch rather than "emoji
|
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
|
||||||
# not supported" on an actual panel. Stripped before drawing rather than
|
# not supported". So event titles get drawn with TWO fonts: this one
|
||||||
# left to render as tofu. Covers the standard Unicode emoji blocks (plus
|
# (monochrome Noto Emoji, OFL-1.1, vendored at app/fonts/NotoEmoji.ttf --
|
||||||
# skin-tone modifiers, the zero-width joiner used to combine them into
|
# see app/fonts/NotoEmoji-OFL.txt) for actual emoji runs, and the normal
|
||||||
# one glyph, and the variation selector that forces emoji presentation)
|
# text font for everything else -- see _draw_mixed_line. Monochrome
|
||||||
# -- these block ranges are stable even as new individual emoji get
|
# rather than a color emoji font on purpose: this is a 6-color dithered
|
||||||
# added within them, so this doesn't need updating as emoji sets grow.
|
# 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(
|
_EMOJI_PATTERN = re.compile(
|
||||||
"["
|
"["
|
||||||
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
|
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
|
||||||
@@ -121,20 +172,59 @@ _EMOJI_PATTERN = re.compile(
|
|||||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||||
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
|
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
|
||||||
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
|
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
|
||||||
"\U0001F3FB-\U0001F3FF" # skin-tone modifiers
|
|
||||||
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
|
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
|
||||||
"\U00002700-\U000027BF" # dingbats (✂✈✉ 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:
|
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
|
||||||
"""Removes emoji (see _EMOJI_PATTERN above) and collapses whatever
|
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
|
||||||
whitespace that leaves behind -- "\U0001F389 Birthday party" becomes
|
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
|
||||||
"Birthday party", not " Birthday party"."""
|
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
|
||||||
return re.sub(r"\s+", " ", _EMOJI_PATTERN.sub("", text)).strip()
|
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:
|
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
|
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):
|
||||||
if i >= max_rows:
|
if i >= max_rows:
|
||||||
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
||||||
break
|
break
|
||||||
color = _event_color(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
draw.rounded_rectangle([text_x0, y + 2, text_x0 + 10, y + row_h - 7], radius=3, fill=color)
|
_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))
|
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||||
line = f"{time_str} {_strip_emoji(event['summary'])}"
|
prefix = f"{time_str} "
|
||||||
draw_text(img, (text_x0 + 18, y), _truncate_to_width(draw, line, body_font, text_w - 18), body_font)
|
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
|
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
|
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)
|
||||||
break
|
break
|
||||||
color = _event_color(event, owners_seen, palette_rgb)
|
colors = _event_colors(event, owners_seen, palette_rgb)
|
||||||
draw.rounded_rectangle([x0 + 4, y + 1, x0 + 11, y + row_h - 5], radius=2, fill=color)
|
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
|
||||||
summary = _strip_emoji(event["summary"])
|
if event["all_day"]:
|
||||||
text = summary if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {summary}"
|
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, emoji_font, col_w - 20)
|
||||||
draw_text(img, (x0 + 16, y), _truncate_to_width(draw, text, chip_font, col_w - 20), chip_font)
|
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
|
y += row_h
|
||||||
|
|
||||||
return img
|
return img
|
||||||
@@ -516,7 +616,11 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
|
|||||||
dot_x = x0 + 8
|
dot_x = x0 + 8
|
||||||
dot_y = y0 + row_h - dot_r * 2 - 6
|
dot_y = y0 + row_h - dot_r * 2 - 6
|
||||||
for i, event in enumerate(day_events[:4]):
|
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)
|
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
|
dot_x += dot_r * 2 + 5
|
||||||
if len(day_events) > 4:
|
if len(day_events) > 4:
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user