Files
ObsidianDragon/scripts/build_emoji_subset.py
DanS 11de117331 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>
2026-07-16 17:28:50 -05:00

88 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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+260026FF 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()