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:
@@ -67,7 +67,8 @@
|
||||
//#define IMGUI_USE_LEGACY_CRC32_ADLER
|
||||
|
||||
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
|
||||
//#define IMGUI_USE_WCHAR32
|
||||
//---- Enabled so chat can render emoji (U+1F300+, above the BMP) — see Typography::loadFont emoji merge (Q12).
|
||||
#define IMGUI_USE_WCHAR32
|
||||
|
||||
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
|
||||
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.
|
||||
|
||||
BIN
res/fonts/NotoEmoji-Subset.ttf
Normal file
BIN
res/fonts/NotoEmoji-Subset.ttf
Normal file
Binary file not shown.
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()
|
||||
@@ -15,3 +15,4 @@ INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
|
||||
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
|
||||
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
|
||||
INCBIN(noto_emoji_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoEmoji-Subset.ttf");
|
||||
|
||||
@@ -35,4 +35,7 @@ extern "C" {
|
||||
|
||||
extern const unsigned char g_noto_cjk_subset_data[];
|
||||
extern const unsigned int g_noto_cjk_subset_size;
|
||||
|
||||
extern const unsigned char g_noto_emoji_subset_data[];
|
||||
extern const unsigned int g_noto_emoji_subset_size;
|
||||
}
|
||||
|
||||
@@ -325,11 +325,50 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
name, g_noto_cjk_subset_size);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking
|
||||
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
|
||||
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
|
||||
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
|
||||
static const char* const kEmojiFonts[] = {
|
||||
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
|
||||
};
|
||||
bool wantEmoji = false;
|
||||
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
|
||||
if (wantEmoji && g_noto_emoji_subset_size > 0) {
|
||||
void* emojiCopy = IM_ALLOC(g_noto_emoji_subset_size);
|
||||
memcpy(emojiCopy, g_noto_emoji_subset_data, g_noto_emoji_subset_size);
|
||||
|
||||
ImFontConfig emojiCfg;
|
||||
emojiCfg.FontDataOwnedByAtlas = true;
|
||||
emojiCfg.MergeMode = true; // merge into the text font just loaded
|
||||
emojiCfg.OversampleH = 1;
|
||||
emojiCfg.OversampleV = 1;
|
||||
emojiCfg.PixelSnapH = true;
|
||||
emojiCfg.GlyphMinAdvanceX = 0;
|
||||
// The base Ubuntu font already owns U+2600–26FF etc.; MergeMode keeps the first-loaded glyph,
|
||||
// so its text-style symbols win and only the codepoints it lacks fall through to emoji.
|
||||
static const ImWchar emojiRanges[] = {
|
||||
0x2600, 0x27BF, // Misc Symbols + Dingbats
|
||||
0x2B00, 0x2BFF, // stars (⭐) + arrows
|
||||
0x1F000, 0x1FAFF, // emoji planes (emoticons, pictographs, transport, supplement, extended)
|
||||
0,
|
||||
};
|
||||
emojiCfg.GlyphRanges = emojiRanges;
|
||||
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "NotoEmoji %.0fpx (merge)", size);
|
||||
|
||||
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, g_noto_emoji_subset_size, size, &emojiCfg);
|
||||
if (emojiMerge) {
|
||||
DEBUG_LOGF("Typography: Merged emoji (%u bytes) into %s OK\n", g_noto_emoji_subset_size, name);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
|
||||
|
||||
return font;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user