Files
wadamesh/scripts/build/add-emoji.py
T
Kaj SchittecatandClaude Opus 4.8 308be40b96 touch: beta_44 — on-device text web browser + tappable chat links + French keyboard fix
- New "Web" app: on-device HTTPS text browser (strip HTML to readable text,
  tappable links, back/fwd/refresh, no proxy). Gated to 8 MB boards via
  CAP_WEB_BROWSER; hidden on the 2 MB V4 (can't do the TLS handshake).
- Tappable URLs in chat -> Open in web (8 MB) / Create QR (all boards).
- Drawn vector globe icon for the Web tile.
- Fix: French AZERTY on-screen keyboard lost its spacebar/OK/kbd-switch row
  (a stray "" mid-map terminated the LVGL button map early). Restored.
- V4 web fetch resilience (192->96->48->24 KB buffer fallback).
- Home signal box: "tap for details" cue back at the top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:26:05 +02:00

206 lines
8.6 KiB
Python

#!/usr/bin/env python3
# Bake a few EXTRA Noto colour emoji into src/ui-touch/emoji_data.{c,h} WITHOUT
# regenerating (and re-downloading) the existing 252 — those stay byte-identical,
# so there's no Noto-`main` art drift. Splices new glyph arrays + sorted lookup
# entries into the file in place. Re-run is idempotent (new cps replace, existing
# untouched). PX must match the existing bake (16) and the imgfont in UITask.
#
# python3 scripts/build/add-emoji.py
#
# Source art: googlefonts/noto-emoji png/128 (OFL). RGB565+alpha, swap=0.
import os, re, sys, urllib.request
from PIL import Image
PX = 16
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
OUTDIR = os.path.join(ROOT, "src", "ui-touch")
OUTC = os.path.join(OUTDIR, "emoji_data.c")
OUTH = os.path.join(OUTDIR, "emoji_data.h")
CACHE = os.path.join(ROOT, "data", "emoji-cache")
BASE = "https://raw.githubusercontent.com/googlefonts/noto-emoji/main/png/128/emoji_u{}.png"
os.makedirs(CACHE, exist_ok=True)
# Single-codepoint emoji: (codepoint, noto-filename-stem)
SINGLE = [
(0x26BD, "26bd"), # soccer ball âš½
(0x1F3C8, "1f3c8"), # american football ðŸ
(0x1F5FF, "1f5ff"), # moai 🗿
# objects/food listed in the picker (k_emoji_items) but never baked -> they
# rendered as the notdef box on-device (the beta_38 "emoji tofu"). Bake them.
(0x2614, "2614"), # umbrella with rain
(0x2615, "2615"), # hot beverage
(0x1F355, "1f355"), # pizza
(0x1F382, "1f382"), # birthday cake
(0x1F680, "1f680"), # rocket
(0x1F4F7, "1f4f7"), # camera
(0x1F4F1, "1f4f1"), # mobile phone
(0x1F4BB, "1f4bb"), # laptop
(0x1F310, "1f310"), # globe with meridians (Web app icon)
(0x1F4C5, "1f4c5"), # calendar
(0x1F4E1, "1f4e1"), # satellite antenna
(0x1F4E7, "1f4e7"), # e-mail
(0x1F3E0, "1f3e0"), # house
(0x1F697, "1f697"), # car
(0x231A, "231a"), # watch (sat between car + bulb in the picker, still tofu)
]
# ZWJ sequences baked as ONE combined image, keyed on the lead codepoint, with the
# trailing visible symbol(s) mapped to the zero-width glyph so the whole UTF-8
# sequence renders as the single combined emoji. The bare lead cp (a plain waving
# white flag U+1F3F3) is not offered anywhere in the UI, so reusing it as the key
# is safe. (noto-emoji drops the FE0F variation selectors from png filenames.)
SEQ = [
# (key_cp, noto-stem, [extra cps -> zero])
(0x1F3F3, "1f3f3_200d_26a7", [0x26A7]), # transgender flag ðŸ³ï¸â€âš§ï¸
]
# Country/region flags: a regional-indicator PAIR (e.g. LS = U+1F1F1 U+1F1F8) baked
# as ONE image keyed on the LEAD indicator, the trailing indicator mapped to the
# zero-width glyph (same trick as SEQ). Noto's png/128 ships no flags, so pull the
# flat rectangle from the same Noto project's third_party/region-flags. No bare
# regional indicator is ever offered in the UI, so reusing the lead cp as the key
# is safe — and the picker currently has no other flag using these indicators.
FLAGS = [
# (lead_cp, trail_cp, ISO-3166-alpha-2)
(0x1F1F1, 0x1F1F8, "LS"), # Lesotho
]
def fetch(stem):
fn = os.path.join(CACHE, "u_{}.png".format(stem))
if os.path.exists(fn) and os.path.getsize(fn) > 0:
return fn
for url in (BASE.format(stem), BASE.format(stem + "_fe0f")):
try:
req = urllib.request.Request(url, headers={"User-Agent": "emoji-gen"})
data = urllib.request.urlopen(req, timeout=20).read()
with open(fn, "wb") as f:
f.write(data)
return fn
except Exception:
continue
return None
def fetch_flag(iso):
fn = os.path.join(CACHE, "flag_{}.png".format(iso))
if os.path.exists(fn) and os.path.getsize(fn) > 0:
return fn
url = ("https://raw.githubusercontent.com/googlefonts/noto-emoji/main"
"/third_party/region-flags/png/{}.png".format(iso))
try:
req = urllib.request.Request(url, headers={"User-Agent": "emoji-gen"})
data = urllib.request.urlopen(req, timeout=20).read()
with open(fn, "wb") as f:
f.write(data)
return fn
except Exception:
return None
def to_rgb565a8(img):
# IDENTICAL to gen-emoji-font.py: autocrop transparent margin, bottom-align to
# the text baseline, emit [lo, hi, alpha] per pixel (LV_COLOR_16_SWAP=0).
PAD_BOTTOM = 0
img = img.convert("RGBA")
bbox = img.split()[3].getbbox()
if bbox:
img = img.crop(bbox)
w, h = img.size
avail_h = PX - PAD_BOTTOM
scale = min(PX / w, avail_h / h)
nw, nh = max(1, round(w * scale)), max(1, round(h * scale))
content = img.resize((nw, nh), Image.LANCZOS)
canvas = Image.new("RGBA", (PX, PX), (0, 0, 0, 0))
canvas.paste(content, ((PX - nw) // 2, PX - PAD_BOTTOM - nh))
out = bytearray()
for (r, g, b, a) in canvas.getdata():
v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
out += bytes((v & 0xFF, (v >> 8) & 0xFF, a))
return out
# ---- bake the new glyphs ----
new_defs = [] # (cp, ename, bytes)
new_entries = [] # (cp, ref)
for cp, stem in SINGLE:
fn = fetch(stem)
if not fn:
sys.exit("FAILED to fetch U+{:X} ({})".format(cp, stem))
new_defs.append((cp, "e_{:x}".format(cp), to_rgb565a8(Image.open(fn))))
new_entries.append((cp, "d_{:x}".format(cp)))
for key_cp, stem, zeros in SEQ:
fn = fetch(stem)
if not fn:
sys.exit("FAILED to fetch combined {} for U+{:X}".format(stem, key_cp))
new_defs.append((key_cp, "e_{:x}".format(key_cp), to_rgb565a8(Image.open(fn))))
new_entries.append((key_cp, "d_{:x}".format(key_cp)))
for z in zeros:
new_entries.append((z, "d_zero"))
for lead_cp, trail_cp, iso in FLAGS:
fn = fetch_flag(iso)
if not fn:
sys.exit("FAILED to fetch flag {} for U+{:X}".format(iso, lead_cp))
new_defs.append((lead_cp, "e_{:x}".format(lead_cp), to_rgb565a8(Image.open(fn))))
new_entries.append((lead_cp, "d_{:x}".format(lead_cp)))
new_entries.append((trail_cp, "d_zero"))
# ---- splice into emoji_data.c ----
src = open(OUTC).read()
TYPE_MARK = "\ntypedef struct { uint32_t cp;"
FUNC_MARK = "const lv_img_dsc_t* emojiGlyphLookup(uint32_t cp) {"
head = src[:src.index(TYPE_MARK)] # header + includes + all glyph defs
func = src[src.index(FUNC_MARK):] # the binary-search getter (verbatim)
# Idempotency: on a RE-RUN, `head` still holds the e_/d_ glyph defs spliced in by a
# PRIOR run (they live before the typedef), so blindly re-appending new_defs would
# redefine them ("redefinition of e_26bd"). Strip any def for a cp we're about to
# re-bake; the lookup table dedups separately (bycp), so this only touches defs.
for _cp, _ename, _data in new_defs:
_hx = "{:x}".format(_cp)
head = re.sub(r"static const uint8_t e_" + _hx + r"\[\] = [^;]*;\n", "", head)
head = re.sub(r"static const lv_img_dsc_t d_" + _hx + r" = [^;]*;\n", "", head)
# Existing lookup entries (includes the FE0F/FE0E/200D -> d_zero rows).
existing = [(int(cp, 16), ref) for cp, ref in
re.findall(r"\{\s*(0x[0-9A-Fa-f]+)u,\s*&(\w+)\s*\}", src)]
bycp = {cp: ref for cp, ref in existing}
for cp, ref in new_entries: # new cps win; never demote a real glyph to zero
if not (ref == "d_zero" and bycp.get(cp, "d_zero") != "d_zero"):
bycp[cp] = ref
entries = sorted(bycp.items())
count = sum(1 for _, ref in entries if ref != "d_zero")
# New glyph array + dsc definitions (appended after the existing ones).
defs = []
for cp, ename, data in new_defs:
defs.append("static const uint8_t {}[] = {{{}}};".format(ename, ",".join(str(b) for b in data)))
defs.append("static const lv_img_dsc_t d_{:x} = {{ {{ LV_IMG_CF_TRUE_COLOR_ALPHA, 0, 0, {}, {} }}, sizeof({}), {} }};"
.format(cp, PX, PX, ename, ename))
out = []
out.append(head.rstrip("\n"))
out.append("")
out.append("\n".join(defs))
out.append("")
out.append("typedef struct { uint32_t cp; const lv_img_dsc_t* dsc; } EmojiGlyph;")
out.append("static const EmojiGlyph kGlyphs[] = {")
for cp, ref in entries:
out.append(" {{ 0x{:X}u, &{} }},".format(cp, ref))
out.append("};")
out.append("const uint16_t kEmojiGlyphCount = {};".format(count))
out.append("")
out.append(func.rstrip("\n"))
out.append("")
open(OUTC, "w").write("\n".join(out))
# ---- header comment count ----
h = open(OUTH).read()
h = re.sub(r"// \d+ Noto colour emoji", "// {} Noto colour emoji".format(count), h, count=1)
open(OUTH, "w").write(h)
print("added {} glyphs -> {} total ({} lookup rows incl zero-width)".format(
len(new_defs), count, len(entries)))