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:
parent
59bc0e556c
commit
4e1b4882d4
6 changed files with 341 additions and 0 deletions
118
Assets/Scripts/Editor/EmojiCheck.cs
Normal file
118
Assets/Scripts/Editor/EmojiCheck.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/EmojiCheck.cs.meta
Normal file
2
Assets/Scripts/Editor/EmojiCheck.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c6f940add256a754daac8460fc79480d
|
||||
74
Assets/Scripts/Editor/EmojiFont.cs
Normal file
74
Assets/Scripts/Editor/EmojiFont.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/EmojiFont.cs.meta
Normal file
2
Assets/Scripts/Editor/EmojiFont.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bd3a902defe23f14fbe821ca9fb26a08
|
||||
122
tools/emoji/fetch_emoji_font.py
Normal file
122
tools/emoji/fetch_emoji_font.py
Normal 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()
|
||||
23
tools/emoji/requirements.txt
Normal file
23
tools/emoji/requirements.txt
Normal 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
|
||||
Loading…
Reference in a new issue