Add the emoji font pipeline: pinned Twemoji fetch, font builder, rendering check

tools/emoji/fetch_emoji_font.py builds Assets/Fonts/Emoji/Twemoji.ttf from the
pinned jdecked/twemoji release with nanoemoji (glyf_colr_0); EmojiFont.Build is
the one writer of Emoji.asset and TMP Settings' emoji routing; EmojiCheck.Run
renders every single-code-point emoji and the Pirates names through TMP. The
generated font, lock and asset land in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 18:43:39 +02:00
commit 4e1b4882d4
6 changed files with 341 additions and 0 deletions

View file

@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using TMPro;
using UnityEditor;
using UnityEngine;
// Every single-code-point emoji the pinned font set draws, and the Pirates product names, render through an
// ordinary TMP text on the game's body font with no missing code point and with ink in the emoji atlas.
// Run as -executeMethod EmojiCheck.Run (exit 1 on failure in batchmode, as EngineAssetCheck). Corpus:
// tools/emoji/emoji-singles.txt; -emojiLimit N spreads N entries evenly across it for the spike.
public static class EmojiCheck
{
const string BodyFontPath = "Assets/Fonts/Ubuntu/Ubuntu-L Small SDF.asset";
const int Chunk = 64;
// The eight Pirates products (addenda "The Pirates record r2"), written as code points.
static readonly string[] PiratesNames =
{
"\U0001FAF3\U0001F52B", "\U0001FAA6\U0001F920", "\U0001F9FE\U0001F6AB", "\U0001F3A8\U0001F648",
"\U0001F525", "\U0001F440\U0001F3AF", "\U0001F511\U0001F3C3\U0001F4A8", "\U0001F4E6\U0001F92B",
};
[MenuItem("Aetheria/Text/Check Emoji Rendering")]
public static void Run()
{
var failures = new List<string>();
var summary = RunCheck(failures);
foreach (var failure in failures.Take(20)) Debug.LogError(failure);
Debug.Log($"EmojiCheck: {summary}; {failures.Count} failure(s).");
if (Application.isBatchMode) EditorApplication.Exit(failures.Count == 0 ? 0 : 1);
}
static string RunCheck(List<string> failures)
{
var emoji = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(EmojiFont.AssetPath);
var body = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(BodyFontPath);
if (emoji == null || body == null)
{
failures.Add("EmojiCheck: Emoji.asset or the body font is missing; run EmojiFont.Build first.");
return "nothing checked";
}
var corpus = LoadSingles();
var missing = new HashSet<int>();
TMP_Text.MissingCharacterEventCallback onMissing = (unicode, index, source, asset, component) => missing.Add(unicode);
TMP_Text.OnMissingCharacter += onMissing;
var canvas = new GameObject("EmojiCheck", typeof(Canvas));
var text = new GameObject("Text", typeof(RectTransform)).AddComponent<TextMeshProUGUI>();
text.transform.SetParent(canvas.transform, false);
text.font = body;
text.textWrappingMode = TextWrappingModes.NoWrap;
text.rectTransform.sizeDelta = new Vector2(100000, 100000);
int viaEmoji = 0, viaOther = 0, blank = 0;
try
{
void Render(string content, string label)
{
text.text = content;
text.ForceMeshUpdate();
for (var i = 0; i < text.textInfo.characterCount; i++)
{
var info = text.textInfo.characterInfo[i];
if (info.character == ' ' || info.elementType != TMP_TextElementType.Character) continue;
if (info.fontAsset == emoji) viaEmoji++; else viaOther++;
if (info.fontAsset == emoji && info.textElement.glyph.glyphRect.width <= 0)
{
blank++;
failures.Add($"blank glyph U+{(int)info.character:X4} in {label}");
}
}
}
for (var i = 0; i < corpus.Count; i += Chunk)
{
var slice = corpus.Skip(i).Take(Chunk).ToList();
Render(string.Join(" ", slice.Select(char.ConvertFromUtf32)), $"chunk {i}");
}
foreach (var name in PiratesNames) Render(name, "Pirates name");
}
finally
{
TMP_Text.OnMissingCharacter -= onMissing;
UnityEngine.Object.DestroyImmediate(canvas);
}
foreach (var cp in missing.OrderBy(c => c)) failures.Add($"missing code point U+{cp:X4}");
long ink = 0, colour = 0;
foreach (var page in emoji.atlasTextures)
{
var pixels = page.GetPixels32();
ink += pixels.LongCount(p => p.a != 0);
colour += pixels.LongCount(p => p.a != 0 && (p.r != p.g || p.g != p.b));
}
if (ink == 0) failures.Add("the emoji atlas holds no ink after rendering");
else if (colour == 0) failures.Add("the emoji atlas holds no coloured pixel: the glyphs rendered monochrome");
// Dynamic atlas content is cache: drop it so this check leaves the asset as Build wrote it.
emoji.ClearFontAssetData(true);
return $"checked {corpus.Count} single code points + {PiratesNames.Length} Pirates names, {missing.Count} missing, " +
$"{viaEmoji} glyphs via the emoji asset, {viaOther} via other fonts, {blank} blank, {ink} atlas ink pixels of which {colour} coloured";
}
static List<int> LoadSingles()
{
var path = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "tools", "emoji", "emoji-singles.txt");
var all = File.ReadAllLines(path).Where(l => l.Length > 0).Select(l => Convert.ToInt32(l, 16)).ToList();
var args = Environment.GetCommandLineArgs();
var at = Array.IndexOf(args, "-emojiLimit");
if (at < 0 || at + 1 >= args.Length) return all;
var limit = int.Parse(args[at + 1]);
return Enumerable.Range(0, limit).Select(i => all[i * all.Count / limit]).ToList();
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c6f940add256a754daac8460fc79480d

View file

@ -0,0 +1,74 @@
using System.Collections.Generic;
using TMPro;
using UnityEditor;
using UnityEngine;
using UnityEngine.TextCore.LowLevel;
// The one writer of the emoji font asset and of TMP Settings' emoji routing. The font file is the
// output of tools/emoji/fetch_emoji_font.py; the asset is a dynamic COLOR font asset whose atlas pages
// are cache, filled on first use at runtime and cleared here. Every TMP text reaches emoji through
// TMP Settings' emoji fallback and global fallback list; no component names an emoji font.
public static class EmojiFont
{
public const string FontPath = "Assets/Fonts/Emoji/Twemoji.ttf";
public const string AssetPath = "Assets/Fonts/Emoji/Emoji.asset";
// Colour outlines (COLRv0) are rasterised at one size; 109 is the strike size a CBDT set needs.
const int SamplingPointSize = 64;
const int AtlasPadding = 4;
const int AtlasSize = 2048;
// Menu entry for interactive use; -executeMethod EmojiFont.Build for batchmode, exit 1 on failure.
[MenuItem("Aetheria/Text/Build Emoji Font")]
public static void Build()
{
var ok = TryBuild(out var message);
if (ok) Debug.Log(message); else Debug.LogError(message);
if (Application.isBatchMode) EditorApplication.Exit(ok ? 0 : 1);
}
static bool TryBuild(out string message)
{
var font = AssetDatabase.LoadAssetAtPath<Font>(FontPath);
if (font == null)
{
message = $"EmojiFont: {FontPath} is missing; run tools/emoji/fetch_emoji_font.py.";
return false;
}
// An existing asset keeps its GUID, so a second Build changes nothing the settings point at.
var asset = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(AssetPath);
if (asset == null)
{
asset = TMP_FontAsset.CreateFontAsset(font, SamplingPointSize, AtlasPadding, GlyphRenderMode.COLOR,
AtlasSize, AtlasSize, AtlasPopulationMode.Dynamic, enableMultiAtlasSupport: true);
if (asset == null)
{
message = $"EmojiFont: TMP could not create a font asset from {FontPath}.";
return false;
}
asset.name = "Emoji";
AssetDatabase.CreateAsset(asset, AssetPath);
foreach (var page in asset.atlasTextures)
{
page.name = "Emoji Atlas";
AssetDatabase.AddObjectToAsset(page, asset);
}
asset.material.name = "Emoji Atlas Material";
AssetDatabase.AddObjectToAsset(asset.material, asset);
}
else asset.ClearFontAssetData(true);
// Text-presentation emoji (U+263A without FE0F) are not routed through the emoji list, so the
// asset is also the global fallback.
TMP_Settings.emojiFallbackTextAssets = new List<TMP_Asset> { asset };
TMP_Settings.fallbackFontAssets = new List<TMP_FontAsset> { asset };
TMP_Settings.defaultSpriteAsset = null;
EditorUtility.SetDirty(asset);
EditorUtility.SetDirty(TMP_Settings.instance);
AssetDatabase.SaveAssets();
message = "EmojiFont: built " + AssetPath + " and routed TMP Settings' emoji fallback to it.";
return true;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bd3a902defe23f14fbe821ca9fb26a08

View file

@ -0,0 +1,122 @@
"""Build Assets/Fonts/Emoji/Twemoji.ttf from the pinned Twemoji release.
Run with the venv that has tools/emoji/requirements.txt installed:
py -3 tools/emoji/fetch_emoji_font.py [--cache DIR] [--jobs N]
Downloads the jdecked/twemoji release source archive (sha256 pinned below), runs nanoemoji
(glyf_colr_0) over its SVGs, and writes the font, the licence text, the single-code-point corpus
the editor smoke reads, and emoji-font.lock. The font is the only artifact Unity consumes; nothing
here is hand-made art. A second run with the same pins must reproduce the font's sha256.
"""
import argparse, hashlib, os, re, shutil, subprocess, sys, tarfile, tempfile, urllib.request
from importlib import metadata
from pathlib import Path
TWEMOJI_VERSION = "v17.0.3"
TWEMOJI_URL = "https://github.com/jdecked/twemoji/archive/refs/tags/v17.0.3.tar.gz"
TWEMOJI_SHA256 = "a0855654b633045ae2337537e77f1bb4361162f7fcd910e613eaab1d6d9c5fca"
# 2026-06-01T22:21:37Z, the release's publication time: fixes head.created/modified for reproducibility.
SOURCE_DATE_EPOCH = "1780352497"
FAMILY = "Twemoji"
ROOT = Path(__file__).resolve().parents[2]
FONT_DIR = ROOT / "Assets" / "Fonts" / "Emoji"
SINGLES = ROOT / "tools" / "emoji" / "emoji-singles.txt"
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
def download(cache):
archive = cache / f"twemoji-{TWEMOJI_VERSION}.tar.gz"
if not archive.exists():
cache.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(TWEMOJI_URL, archive)
got = sha256(archive)
if got != TWEMOJI_SHA256:
archive.unlink()
sys.exit(f"archive sha256 {got} != pinned {TWEMOJI_SHA256}")
return archive
def licence_text(root):
readme = (root / "README.md").read_text(encoding="utf-8")
attribution = re.search(r"## Attribution Requirements\n\n(.*?)\n\n## ", readme, re.S).group(1)
graphics = re.search(r"Graphics licensed under CC-BY 4.0: \S+", readme).group(0)
copyright_lines = [l for l in (root / "LICENSE").read_text(encoding="utf-8").splitlines() if l.startswith("Copyright")]
return (
f"Twemoji {TWEMOJI_VERSION}: {TWEMOJI_URL}\n"
+ "\n".join(copyright_lines) + "\n" + graphics + "\n\n"
"Change notice (CC BY 4.0 section 3(a)(1)(B)): the SVG graphics are converted to a COLRv0 colour\n"
"font (Twemoji.ttf) by tools/emoji/fetch_emoji_font.py using nanoemoji. No artwork is edited.\n\n"
"Attribution Requirements (upstream README)\n\n" + attribution + "\n\n"
"LICENSE-GRAPHICS (upstream, verbatim)\n"
"=======================================================================\n\n"
+ (root / "LICENSE-GRAPHICS").read_text(encoding="utf-8")
)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cache", type=Path, default=Path(tempfile.gettempdir()) / "aetheria-emoji-cache")
ap.add_argument("--jobs", type=int, default=max(1, min(4, (os.cpu_count() or 2) // 2)))
args = ap.parse_args()
archive = download(args.cache)
work = args.cache / f"build-{TWEMOJI_VERSION}"
if work.exists():
shutil.rmtree(work)
work.mkdir(parents=True)
with tarfile.open(archive) as tar:
tar.extractall(work / "src", filter="data")
src = next((work / "src").iterdir())
svgs = sorted((src / "assets" / "svg").glob("*.svg"))
env = dict(os.environ, SOURCE_DATE_EPOCH=SOURCE_DATE_EPOCH)
# nanoemoji shells out to ninja and picosvg by name; they live beside this interpreter.
env["PATH"] = str(Path(sys.executable).parent) + os.pathsep + env["PATH"]
build = work / "build"
# 4000 paths overflow the Windows command line, so the inputs travel in an absl flagfile.
flagfile = work / "inputs.flags"
flagfile.write_text("".join(f"{p.as_posix()}\n" for p in svgs), encoding="utf-8")
run = [sys.executable, "-c", "from nanoemoji.nanoemoji import main; main()", "--color_format", "glyf_colr_0",
"--family", FAMILY, "--output_file", f"{FAMILY}.ttf", "--build_dir", str(build), "--noexec_ninja",
f"--flagfile={flagfile}"]
subprocess.run(run, check=True, env=env, cwd=work)
subprocess.run(["ninja", "-j", str(args.jobs), "-C", str(build)], check=True, env=env)
FONT_DIR.mkdir(parents=True, exist_ok=True)
out = FONT_DIR / f"{FAMILY}.ttf"
shutil.copyfile(build / f"{FAMILY}.ttf", out)
(FONT_DIR / "LICENSE.txt").write_text(licence_text(src), encoding="utf-8", newline="\n")
# The smoke's corpus: every single-code-point glyph the pinned release draws.
singles = sorted(p.stem for p in svgs if re.fullmatch(r"[0-9a-f]{4,6}", p.stem))
SINGLES.write_text("".join(f"{s}\n" for s in singles), encoding="utf-8", newline="\n")
tools = {n: metadata.version(n) for n in ("nanoemoji", "fonttools", "picosvg", "ninja")}
lock = [
f"source_url={TWEMOJI_URL}", f"source_version={TWEMOJI_VERSION}", f"source_sha256={TWEMOJI_SHA256}",
f"svg_count={len(svgs)}", f"single_codepoint_count={len(singles)}",
*(f"tool_{n}={v}" for n, v in tools.items()),
f"source_date_epoch={SOURCE_DATE_EPOCH}", "color_format=glyf_colr_0",
f"font_file={out.name}", f"font_sha256={sha256(out)}", f"singles_sha256={sha256(SINGLES)}",
]
previous = (FONT_DIR / "emoji-font.lock")
if previous.exists():
old = dict(l.split("=", 1) for l in previous.read_text().splitlines() if "=" in l)
if old.get("font_sha256") != sha256(out):
print(f"NOT REPRODUCED: font_sha256 {sha256(out)} != lock {old.get('font_sha256')}")
else:
print("reproduced: font_sha256 equals the lock")
previous.write_text("\n".join(lock) + "\n", encoding="utf-8", newline="\n")
print("\n".join(lock))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,23 @@
# Pinned toolchain for fetch_emoji_font.py (py -3 -m venv, then pip install -r this file).
# nanoemoji is the spec's tool; the rest is its resolved dependency set at 0.16.0.
absl-py==2.5.0
attrs==26.1.0
booleanOperations==0.10.0
cffsubr==0.4.0
fontMath==0.10.0
fonttools==4.66.1
lxml==6.1.3
nanoemoji==0.16.0
ninja==1.13.2
picosvg==0.23.0
pillow==12.3.0
pngquant-cli==3.0.3
pyclipper==1.4.0
regex==2026.9.29
resvg-cli==0.44.0
skia-pathops==0.9.2
toml==0.10.2
typing_extensions==4.16.0
ufo2ft==3.9.1
ufoLib2==0.19.0
zopfli==0.4.3