feat(chat): render emoji in messages and user text (Q12)
Merge a monochrome Noto Emoji subset into the text fonts so chat messages, the composer, contact names, and memos render emoji (😀 🎉 ❤ 🔥 👍 …) instead of tofu. - Enable IMGUI_USE_WCHAR32 (imconfig.h): emoji live above the BMP (U+1F300+), so 16-bit ImWchar literally can't address them. This widens ImWchar build-wide; the only ImWchar uses in-tree are glyph-range arrays and one BMP private-use codepoint, so nothing else is affected. Tests + full build pass. - Bundle res/fonts/NotoEmoji-Subset.ttf — the OFL monochrome Noto Emoji (color CBDT/COLR fonts can't be rasterized by ImGui's stb_truetype) pinned to wght=400 and subset to the emoji planes (1411 glyphs, 747 KB). Reproducible via scripts/build_emoji_subset.py. Embedded via INCBIN like the CJK subset. - Typography::loadFont merges it (MergeMode) only into the small text fonts (Body/Subtitle/Caption/Button) — not headers, which don't need 1400 emoji. The base font keeps precedence for U+2600–26FF, so text-style symbols stay. Limits: ImGui does no shaping, so single-codepoint emoji render but ZWJ sequences (family/profession) and regional-indicator flags won't compose; emoji are monochrome (the OS emoji picker still inputs them fine, and the composer byte counter already counts their 4-byte UTF-8 cost against the on-chain cap). Verified headless: sizeof(ImWchar)==4 and every probed emoji is in-font and bakes into the atlas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
scripts/build_emoji_subset.py
Normal file
87
scripts/build_emoji_subset.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a monochrome Noto Emoji subset for the chat/message UI.
|
||||
|
||||
Dear ImGui rasterizes fonts with stb_truetype, which handles only monochrome
|
||||
(outline `glyf`) fonts — NOT color emoji (CBDT/sbix/COLR). So we use Google's
|
||||
*monochrome* Noto Emoji (github.com/google/fonts, ofl/notoemoji, OFL-licensed)
|
||||
and merge it into the text fonts (see Typography::loadFont, the block after the
|
||||
CJK merge). ImGui renders one glyph per codepoint with no shaping, so ZWJ
|
||||
sequences / regional-indicator flags won't compose — single-codepoint emoji
|
||||
(😀 🎉 ❤ 🔥 👍 …) render fine, which covers the overwhelming majority of use.
|
||||
|
||||
Source is the variable font pinned to wght=400 → static, then subset to the
|
||||
emoji planes plus the higher symbol/star ranges the base UI font doesn't cover.
|
||||
The base Ubuntu font already owns U+2600–26FF etc.; ImGui's MergeMode gives the
|
||||
first-loaded glyph precedence, so those stay text-styled and only the codepoints
|
||||
the base lacks fall through to this font.
|
||||
|
||||
Get the source once (OFL, redistributable):
|
||||
curl -fsSL -o /tmp/NotoEmoji-VF.ttf \
|
||||
'https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf'
|
||||
|
||||
Then: python3 scripts/build_emoji_subset.py
|
||||
Output: res/fonts/NotoEmoji-Subset.ttf (committed; embedded via INCBIN)
|
||||
"""
|
||||
import os
|
||||
from fontTools import ttLib, subset
|
||||
from fontTools.varLib.instancer import instantiateVariableFont
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SOURCE_VF = '/tmp/NotoEmoji-VF.ttf'
|
||||
STATIC = '/tmp/NotoEmoji-Static.ttf'
|
||||
OUTPUT = os.path.join(ROOT, 'res', 'fonts', 'NotoEmoji-Subset.ttf')
|
||||
|
||||
# Emoji codepoints to keep. Ranges are inclusive.
|
||||
RANGES = [
|
||||
(0x1F000, 0x1FAFF), # all the main emoji planes (emoticons, pictographs, transport, supplement, extended)
|
||||
(0x2600, 0x27BF), # Miscellaneous Symbols + Dingbats
|
||||
(0x2B00, 0x2BFF), # stars (⭐ 2B50) and misc arrows
|
||||
(0xFE00, 0xFE0F), # variation selectors (VS16 emoji-style)
|
||||
(0x2194, 0x21AA), # arrows used as emoji
|
||||
(0x231A, 0x231B), # ⌚ ⌛
|
||||
(0x23E9, 0x23FA), # media-control emoji
|
||||
(0x25AA, 0x25FE), # small squares
|
||||
]
|
||||
SINGLES = [0x200D, 0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299,
|
||||
0x00A9, 0x00AE, 0x2122, 0x2139, 0x24C2]
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(SOURCE_VF):
|
||||
raise SystemExit(f"missing source font {SOURCE_VF} — see the header for the curl command")
|
||||
|
||||
# 1. Pin the weight axis so stb_truetype rasterizes a clean static instance.
|
||||
f = ttLib.TTFont(SOURCE_VF)
|
||||
if 'fvar' in f:
|
||||
instantiateVariableFont(f, {'wght': 400}, inplace=True)
|
||||
f.save(STATIC)
|
||||
|
||||
unicodes = list(SINGLES)
|
||||
for lo, hi in RANGES:
|
||||
unicodes.extend(range(lo, hi + 1))
|
||||
|
||||
opts = subset.Options()
|
||||
opts.layout_features = [] # ImGui does no shaping — drop GSUB/GPOS
|
||||
opts.name_IDs = []
|
||||
opts.notdef_outline = True
|
||||
opts.glyph_names = False
|
||||
opts.drop_tables = ['GSUB', 'GPOS', 'GDEF', 'morx', 'kern']
|
||||
|
||||
font = subset.load_font(STATIC, opts)
|
||||
ss = subset.Subsetter(options=opts)
|
||||
ss.populate(unicodes=unicodes)
|
||||
ss.subset(font)
|
||||
subset.save_font(font, OUTPUT, opts)
|
||||
|
||||
out = ttLib.TTFont(OUTPUT)
|
||||
cmap = out.getBestCmap()
|
||||
color = any(t in out.reader.keys() for t in ('CBDT', 'sbix', 'COLR'))
|
||||
print(f"Output: {OUTPUT}")
|
||||
print(f"Size: {os.path.getsize(OUTPUT)//1024} KB | glyphs: {len(cmap)} | color tables: {color}")
|
||||
if color:
|
||||
raise SystemExit("ERROR: subset has color tables — stb_truetype cannot render it")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user