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
|
//#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...)
|
//---- 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
|
//---- 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.
|
// 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(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.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_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 char g_noto_cjk_subset_data[];
|
||||||
extern const unsigned int g_noto_cjk_subset_size;
|
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,6 +325,45 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
|||||||
name, g_noto_cjk_subset_size);
|
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 {
|
} else {
|
||||||
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
||||||
IM_FREE(fontDataCopy);
|
IM_FREE(fontDataCopy);
|
||||||
|
|||||||
Reference in New Issue
Block a user