diff --git a/Assets/Scripts/Editor/EmojiCheck.cs b/Assets/Scripts/Editor/EmojiCheck.cs new file mode 100644 index 000000000..3623587ce --- /dev/null +++ b/Assets/Scripts/Editor/EmojiCheck.cs @@ -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(); + 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 failures) + { + var emoji = AssetDatabase.LoadAssetAtPath(EmojiFont.AssetPath); + var body = AssetDatabase.LoadAssetAtPath(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(); + 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(); + 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 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(); + } +} diff --git a/Assets/Scripts/Editor/EmojiCheck.cs.meta b/Assets/Scripts/Editor/EmojiCheck.cs.meta new file mode 100644 index 000000000..945280e85 --- /dev/null +++ b/Assets/Scripts/Editor/EmojiCheck.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c6f940add256a754daac8460fc79480d \ No newline at end of file diff --git a/Assets/Scripts/Editor/EmojiFont.cs b/Assets/Scripts/Editor/EmojiFont.cs new file mode 100644 index 000000000..855bece3a --- /dev/null +++ b/Assets/Scripts/Editor/EmojiFont.cs @@ -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(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(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 { asset }; + TMP_Settings.fallbackFontAssets = new List { 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; + } +} diff --git a/Assets/Scripts/Editor/EmojiFont.cs.meta b/Assets/Scripts/Editor/EmojiFont.cs.meta new file mode 100644 index 000000000..5888665c0 --- /dev/null +++ b/Assets/Scripts/Editor/EmojiFont.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd3a902defe23f14fbe821ca9fb26a08 \ No newline at end of file diff --git a/tools/emoji/fetch_emoji_font.py b/tools/emoji/fetch_emoji_font.py new file mode 100644 index 000000000..77e03e6f5 --- /dev/null +++ b/tools/emoji/fetch_emoji_font.py @@ -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() diff --git a/tools/emoji/requirements.txt b/tools/emoji/requirements.txt new file mode 100644 index 000000000..955d3e31b --- /dev/null +++ b/tools/emoji/requirements.txt @@ -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