525 lines
20 KiB
Python
Executable File
525 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerate src/emoji.zig from Unicode and CLDR data.
|
|
|
|
tools/gen-emoji.py # fetch the data, write src/emoji.zig
|
|
tools/gen-emoji.py --check # fail if the checked-in file is stale
|
|
tools/gen-emoji.py --emoji-test PATH --annotations PATH --derived PATH
|
|
|
|
The table it writes is committed, so this runs by hand rather than as part of
|
|
the build: a build that needs the network to compile is a build that fails on a
|
|
train. Re-run it when a new Unicode version lands, or when you want to add a
|
|
synonym to SYNONYMS below.
|
|
|
|
Three inputs, all canonical:
|
|
|
|
emoji-test.txt Unicode's own ordering, grouped and subgrouped, with
|
|
the fully-qualified form of every RGI emoji. This is
|
|
what every emoji keyboard is laid out from.
|
|
annotations/en.xml CLDR's English keywords per emoji — the synonyms that
|
|
make search work ("grin" finding the grinning face).
|
|
annotationsDerived/en.xml
|
|
The same for sequences CLDR derives rather than names
|
|
outright, which is most of the ZWJ ones.
|
|
|
|
Skin-tone variants are left out. Unicode lists 3944 fully-qualified emoji and
|
|
just over half of those are the same gesture five more times; a grid of them is
|
|
harder to look through, not more complete. Every base glyph is present, which
|
|
is the same choice GTK's own emoji chooser, iOS and Slack all make.
|
|
|
|
So is anything newer than MAX_VERSION. Color emoji fonts trail Unicode by a
|
|
year or two, and a glyph the font has never heard of draws as a hex-digit box —
|
|
which in a picker reads as a bug rather than as a font that needs updating. At
|
|
the time of writing, Noto Color Emoji could draw all but 7 of the E17.0
|
|
additions and everything older. Raise MAX_VERSION when fonts have caught up;
|
|
`--max-version 99` turns the cutoff off entirely.
|
|
"""
|
|
|
|
import argparse
|
|
import collections
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
|
|
# "latest" rather than a pinned version: the per-version directories under
|
|
# /Public/emoji/ stop being published once a version ships, so a pinned URL is
|
|
# one that 404s later. What version "latest" turned out to be is recorded in the
|
|
# generated file's header, and MAX_VERSION below is what actually decides the
|
|
# contents.
|
|
EMOJI_TEST = "https://unicode.org/Public/emoji/latest/emoji-test.txt"
|
|
ANNOTATIONS = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotations/en.xml"
|
|
DERIVED = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotationsDerived/en.xml"
|
|
|
|
# Terms no data file will ever give you: what a glyph means to someone labeling
|
|
# a terminal tab. CLDR knows a rocket is a "space ship"; it does not know it is
|
|
# what you reach for when the tab is a deploy.
|
|
SYNONYMS = {
|
|
"🚀": "deploy ship launch release",
|
|
"📦": "release bundle package ship artifact",
|
|
"🏷️": "version tag release",
|
|
"🎉": "ship shipped release celebrate tada",
|
|
"🐛": "bug issue defect regression",
|
|
"🩹": "hotfix patch bandaid",
|
|
"🔥": "hot lit onfire urgent",
|
|
"💥": "crash boom broke",
|
|
"💀": "dead deprecated killed",
|
|
"🦖": "legacy ancient",
|
|
"⚡": "zap fast perf quick",
|
|
"⏱️": "benchmark perf timing latency",
|
|
"🧪": "test experiment trial",
|
|
"🧫": "test lab",
|
|
"🔬": "inspect investigate research",
|
|
"🔍": "search find grep lookup",
|
|
"👀": "review look watch eyes",
|
|
"✅": "pass passing green done ok",
|
|
"❌": "fail failing red broken",
|
|
"⚠️": "warn warning caution",
|
|
"🚧": "wip work in progress unfinished",
|
|
"🏗️": "wip building scaffolding",
|
|
"🧹": "cleanup refactor tidy sweep",
|
|
"♻️": "refactor reuse recycle",
|
|
"🔄": "sync retry refresh reload",
|
|
"🔀": "shuffle random merge",
|
|
"⚙️": "settings config gear options",
|
|
"🔒": "secure private locked",
|
|
"🔓": "public unlocked open",
|
|
"🔑": "auth key password access token secret",
|
|
"🛡️": "security hardening defense",
|
|
"📈": "metrics growth up analytics",
|
|
"📉": "metrics down regression analytics",
|
|
"📊": "metrics analytics stats dashboard",
|
|
"🔔": "alert notification ping",
|
|
"🔕": "mute silence snooze",
|
|
"💻": "dev code local laptop",
|
|
"🖥️": "server desktop box host",
|
|
"🗄️": "database storage archive",
|
|
"🐳": "docker container whale",
|
|
"🐧": "linux tux",
|
|
"🦀": "rust cargo",
|
|
"🐍": "python",
|
|
"🐫": "perl camel",
|
|
"☕": "java coffee jvm",
|
|
"💎": "ruby gem",
|
|
"🐘": "postgres php elephant memory",
|
|
"🍎": "apple mac macos",
|
|
"🪟": "windows",
|
|
"🤖": "bot agent ai automation claude",
|
|
"🧑💻": "dev developer engineer coding",
|
|
"📝": "todo note notes scratch",
|
|
"🗑️": "delete trash remove drop",
|
|
"🌈": "pride rainbow",
|
|
"🎯": "goal target focus",
|
|
"🧭": "navigate direction bearings",
|
|
"🚦": "ci status pipeline signal",
|
|
"🏁": "done finished race",
|
|
"🧊": "freeze frozen cold pinned",
|
|
"🕸️": "stale abandoned cobweb",
|
|
"🧠": "think smart reasoning",
|
|
}
|
|
|
|
# Nothing in a keyword list should be a word you cannot type. Everything else is
|
|
# kept, including the non-ASCII names of places, because a term only has to
|
|
# match to be worth carrying.
|
|
STRIP = re.compile(r"[\"\\|,:;()\[\]{}!?“”]+")
|
|
|
|
SKIN_TONES = range(0x1F3FB, 0x1F400)
|
|
|
|
# Newest emoji version to include. See the note at the top of this file.
|
|
MAX_VERSION = "16.0"
|
|
|
|
|
|
def version_tuple(text):
|
|
""""E16.0" or "16" as something comparable. Unknown sorts newest."""
|
|
try:
|
|
return tuple(int(p) for p in text.lstrip("Ee").split("."))
|
|
except ValueError:
|
|
return (999,)
|
|
|
|
|
|
def read(source):
|
|
"""Contents of a URL or a path, whichever `source` looks like."""
|
|
if source.startswith(("http://", "https://")):
|
|
with urllib.request.urlopen(source, timeout=60) as response:
|
|
return response.read().decode("utf-8")
|
|
with open(source, encoding="utf-8") as handle:
|
|
return handle.read()
|
|
|
|
|
|
def parse_emoji_test(text, max_version):
|
|
"""Unicode's list, in Unicode's order: (glyph, group, subgroup, name)."""
|
|
out = []
|
|
skipped = []
|
|
group = subgroup = ""
|
|
version = "unknown"
|
|
|
|
for line in text.splitlines():
|
|
if line.startswith("# Version:"):
|
|
version = line.split(":", 1)[1].strip()
|
|
continue
|
|
if line.startswith("# group:"):
|
|
group = line.split(":", 1)[1].strip()
|
|
continue
|
|
if line.startswith("# subgroup:"):
|
|
subgroup = line.split(":", 1)[1].strip()
|
|
continue
|
|
if not line.strip() or line.startswith("#"):
|
|
continue
|
|
|
|
codepoints, rest = line.split(";", 1)
|
|
status, _, comment = rest.partition("#")
|
|
if status.strip() != "fully-qualified":
|
|
continue
|
|
|
|
points = [int(c, 16) for c in codepoints.split()]
|
|
if any(p in SKIN_TONES for p in points):
|
|
continue
|
|
|
|
# The comment is "<glyph> E<version> <name>": the version each sequence
|
|
# was introduced in, which is what the font-coverage cutoff reads.
|
|
parts = comment.strip().split(" ", 2)
|
|
introduced = parts[1] if len(parts) > 1 else "E0"
|
|
name = parts[2] if len(parts) > 2 else ""
|
|
|
|
if version_tuple(introduced) > version_tuple(max_version):
|
|
skipped.append(introduced)
|
|
continue
|
|
|
|
out.append(("".join(chr(p) for p in points), group, subgroup, name))
|
|
|
|
return version, out, collections.Counter(skipped)
|
|
|
|
|
|
def parse_annotations(text):
|
|
"""CLDR's keywords and short name per emoji, merged into one dict."""
|
|
keywords = {}
|
|
names = {}
|
|
|
|
for match in re.finditer(
|
|
r'<annotation cp="([^"]*)"(?P<tts> type="tts")?>(.*?)</annotation>',
|
|
text,
|
|
re.DOTALL,
|
|
):
|
|
cp, tts, body = match.group(1), match.group("tts"), match.group(3)
|
|
body = (
|
|
body.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace(""", '"')
|
|
)
|
|
if tts:
|
|
names[cp] = body.strip()
|
|
else:
|
|
keywords.setdefault(cp, []).extend(p.strip() for p in body.split("|"))
|
|
|
|
return keywords, names
|
|
|
|
|
|
def escape(text):
|
|
"""`text` as a Zig string literal body.
|
|
|
|
Only the display name needs this. Keywords go through `tokenize`, which
|
|
drops every character that would have to be escaped in the first place.
|
|
"""
|
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
def tokenize(*phrases):
|
|
"""Lowercase words from `phrases`, deduplicated, in first-seen order."""
|
|
seen = []
|
|
for phrase in phrases:
|
|
if not phrase:
|
|
continue
|
|
cleaned = STRIP.sub(" ", phrase.replace("-", " ").replace("_", " "))
|
|
for word in cleaned.lower().split():
|
|
# A bare "&" survives group names like "Smileys & Emotion".
|
|
if word == "&":
|
|
continue
|
|
if word not in seen:
|
|
seen.append(word)
|
|
return seen
|
|
|
|
|
|
def build(emoji_test, annotations, derived, max_version):
|
|
version, entries, skipped = parse_emoji_test(emoji_test, max_version)
|
|
|
|
keywords, names = parse_annotations(annotations)
|
|
derived_keywords, derived_names = parse_annotations(derived)
|
|
for source, into in ((derived_keywords, keywords), (derived_names, names)):
|
|
for cp, value in source.items():
|
|
if cp not in into:
|
|
into[cp] = value
|
|
|
|
rows = []
|
|
for glyph, group, subgroup, name in entries:
|
|
# CLDR keys on the emoji without its presentation selector as often as
|
|
# with it, so try both before giving up and using Unicode's own name.
|
|
bare = glyph.replace("\ufe0f", "")
|
|
short = names.get(glyph) or names.get(bare) or name
|
|
words = tokenize(
|
|
short,
|
|
" ".join(keywords.get(glyph, keywords.get(bare, []))),
|
|
# The group and subgroup make whole shelves reachable by name:
|
|
# "flags", "fruit", "arrow", "zodiac".
|
|
subgroup,
|
|
group,
|
|
SYNONYMS.get(glyph, ""),
|
|
)
|
|
rows.append((glyph, group, escape(short), " ".join(words)))
|
|
|
|
return version, rows, skipped
|
|
|
|
|
|
def render(version, rows, max_version):
|
|
out = []
|
|
out.append(
|
|
HEADER
|
|
% {"version": version, "count": len(rows), "max_version": max_version}
|
|
)
|
|
|
|
group = None
|
|
for glyph, row_group, short, words in rows:
|
|
if row_group != group:
|
|
group = row_group
|
|
if out[-1].endswith("},\n"):
|
|
out.append("\n")
|
|
out.append(" // ---- %s %s\n" % (group, "-" * max(3, 60 - len(group))))
|
|
out.append(
|
|
' .{ .glyph = "%s", .name = "%s", .keywords = "%s" },\n'
|
|
% (glyph, short, words)
|
|
)
|
|
|
|
out.append("};\n")
|
|
out.append(TESTS)
|
|
return "".join(out)
|
|
|
|
|
|
HEADER = '''//! Every emoji a tab can wear in place of its icon, and the search that finds
|
|
//! them.
|
|
//!
|
|
//! Generated — run `tools/gen-emoji.py` rather than editing this file. It reads
|
|
//! Unicode's `emoji-test.txt` (currently version %(version)s) for the set and its
|
|
//! ordering, and CLDR's English annotations for the keywords, so the picker is
|
|
//! laid out and searchable the same way every other emoji keyboard is.
|
|
//!
|
|
//! %(count)d entries: the RGI set up to emoji %(max_version)s, minus skin-tone variants.
|
|
//! Unicode lists nearly twice this many fully-qualified sequences and the
|
|
//! difference is almost entirely the same gesture in five tones, which makes a
|
|
//! grid longer to look through without making it more complete. The version
|
|
//! cutoff is there because color emoji fonts trail Unicode, and a glyph the
|
|
//! font has never heard of draws as a hex-digit box; the generator's header
|
|
//! explains how to raise it.
|
|
//!
|
|
//! Keywords are CLDR's, plus the group and subgroup a glyph belongs to — so
|
|
//! "fruit", "arrow" and "flags" each bring back a whole shelf — plus a table of
|
|
//! synonyms in the generator for the words a terminal user would actually type:
|
|
//! a rocket answers to "deploy", a bandage to "hotfix", a whale to "docker".
|
|
|
|
const std = @import("std");
|
|
|
|
pub const Emoji = struct {
|
|
/// The glyph itself, NUL-terminated so it can go straight into a label.
|
|
glyph: [:0]const u8,
|
|
|
|
/// CLDR's short name — "red apple", "flag: Kenya" — as the picker shows it
|
|
/// in a tooltip. NUL-terminated for the same reason as the glyph, and kept
|
|
/// apart from `keywords` because a name is one phrase and a keyword list is
|
|
/// twenty words: readable in a tooltip, and unreadable in one.
|
|
name: [:0]const u8,
|
|
|
|
/// Space-separated search terms, lowercase, the name's own words among them.
|
|
keywords: []const u8,
|
|
};
|
|
|
|
/// Whether `emoji` should show for `query`.
|
|
///
|
|
/// Every whitespace-separated term has to match somewhere, which is what makes
|
|
/// "red circle" and "circle red" both land on the same glyph while "red" alone
|
|
/// still brings back the whole family. An empty query matches everything, so
|
|
/// the unfiltered grid falls out of the same path as a filtered one.
|
|
pub fn matches(emoji: Emoji, query: []const u8) bool {
|
|
var terms = std.mem.tokenizeAny(u8, query, " \\t");
|
|
while (terms.next()) |term| {
|
|
if (std.ascii.indexOfIgnoreCase(emoji.keywords, term) == null) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// The table's own copy of `glyph`, or null if it isn't one of ours.
|
|
///
|
|
/// What comes back is static, so a caller can hold it for as long as it likes
|
|
/// with nothing to free — which is what lets a glyph read out of a config file
|
|
/// be handed to something that wants a pointer it can keep. A glyph the table
|
|
/// doesn't have reads as no choice at all: the picker is where these come from,
|
|
/// so anything else is a hand-edited file naming something this build can't
|
|
/// draw at the size the row wants.
|
|
pub fn lookup(glyph: []const u8) ?[:0]const u8 {
|
|
for (table) |entry| {
|
|
if (std.mem.eql(u8, entry.glyph, glyph)) return entry.glyph;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Every emoji the picker offers, in Unicode's order.
|
|
pub const table = [_]Emoji{
|
|
'''
|
|
|
|
TESTS = '''
|
|
// -------------------------------------------------------------------------
|
|
// Tests
|
|
//
|
|
// The generator is what keeps the table right; these are the properties the
|
|
// picker depends on it having. They run against whatever is checked in, so a
|
|
// bad regeneration fails here rather than in the dialog.
|
|
|
|
test "every entry is usable" {
|
|
for (table) |entry| {
|
|
try std.testing.expect(entry.glyph.len > 0);
|
|
try std.testing.expect(std.unicode.utf8ValidateSlice(entry.glyph));
|
|
|
|
// Long enough for the longest RGI sequence, short enough that nothing
|
|
// here is quietly a whole word.
|
|
try std.testing.expect(entry.glyph.len <= 40);
|
|
|
|
try std.testing.expect(entry.name.len > 0);
|
|
try std.testing.expect(std.unicode.utf8ValidateSlice(entry.name));
|
|
|
|
try std.testing.expect(entry.keywords.len > 0);
|
|
for (entry.keywords) |c| try std.testing.expect(!std.ascii.isUpper(c));
|
|
}
|
|
}
|
|
|
|
test "glyphs are distinct" {
|
|
// A child's position in the picker's grid is how the dialog names the entry
|
|
// it shows, and `indexOf` maps the other way by comparing glyphs. Both stop
|
|
// being true if a glyph appears twice.
|
|
for (table, 0..) |entry, i| {
|
|
for (table[i + 1 ..]) |other| {
|
|
try std.testing.expect(!std.mem.eql(u8, entry.glyph, other.glyph));
|
|
}
|
|
}
|
|
}
|
|
|
|
test "the whole set is here" {
|
|
// Unicode 17 has 1914 RGI emoji once skin-tone variants are folded away.
|
|
// A table that has drifted far from that has lost a group.
|
|
try std.testing.expect(table.len > 1800);
|
|
}
|
|
|
|
/// The one entry a search test leans on, looked up rather than indexed so that
|
|
/// regenerating the table doesn't rewrite the tests.
|
|
fn find(glyph: []const u8) Emoji {
|
|
for (table) |entry| {
|
|
if (std.mem.eql(u8, entry.glyph, glyph)) return entry;
|
|
}
|
|
unreachable;
|
|
}
|
|
|
|
test "an empty query matches everything" {
|
|
for (table) |entry| {
|
|
try std.testing.expect(matches(entry, ""));
|
|
try std.testing.expect(matches(entry, " "));
|
|
}
|
|
}
|
|
|
|
test "a term matches part of a keyword, in any case" {
|
|
const rocket = find("\\u{1F680}");
|
|
try std.testing.expect(matches(rocket, "rocket"));
|
|
try std.testing.expect(matches(rocket, "ROCKET"));
|
|
try std.testing.expect(matches(rocket, "Rock"));
|
|
try std.testing.expect(!matches(rocket, "banana"));
|
|
}
|
|
|
|
test "every term has to match, in any order" {
|
|
const green = find("\\u{1F7E2}");
|
|
try std.testing.expect(matches(green, "green circle"));
|
|
try std.testing.expect(matches(green, "circle green"));
|
|
try std.testing.expect(matches(green, " green circle "));
|
|
|
|
// The second term is what rules the other circles out.
|
|
const red = find("\\u{1F534}");
|
|
try std.testing.expect(matches(red, "circle"));
|
|
try std.testing.expect(!matches(red, "green circle"));
|
|
|
|
// And a term matching nothing rules out a glyph the rest of the query hit.
|
|
try std.testing.expect(!matches(green, "green circle sideways"));
|
|
}
|
|
|
|
test "names are the phrase, not the keyword list" {
|
|
// What separates the two fields: the tooltip stays short enough to read.
|
|
for (table) |entry| try std.testing.expect(entry.name.len <= 64);
|
|
|
|
try std.testing.expectEqualStrings("red apple", find("\\u{1F34E}").name);
|
|
try std.testing.expectEqualStrings("rocket", find("\\u{1F680}").name);
|
|
}
|
|
|
|
test "CLDR keywords reach a glyph its name would not" {
|
|
try std.testing.expect(matches(find("\\u{1F600}"), "grin"));
|
|
try std.testing.expect(matches(find("\\u{1F60A}"), "blush"));
|
|
try std.testing.expect(matches(find("\\u{1F4A9}"), "poop"));
|
|
}
|
|
|
|
test "a group or subgroup brings back its whole shelf" {
|
|
try std.testing.expect(matches(find("\\u{1F34E}"), "fruit"));
|
|
try std.testing.expect(matches(find("\\u{1F1FA}\\u{1F1F8}"), "flags"));
|
|
try std.testing.expect(matches(find("\\u{2B06}\\u{FE0F}"), "arrow"));
|
|
}
|
|
|
|
test "synonyms reach the glyph the data would not" {
|
|
try std.testing.expect(matches(find("\\u{1F680}"), "deploy"));
|
|
try std.testing.expect(matches(find("\\u{1FA79}"), "hotfix"));
|
|
try std.testing.expect(matches(find("\\u{1F433}"), "docker"));
|
|
try std.testing.expect(matches(find("\\u{1F427}"), "linux"));
|
|
try std.testing.expect(matches(find("\\u{1F980}"), "rust"));
|
|
try std.testing.expect(matches(find("\\u{2705}"), "pass"));
|
|
}
|
|
'''
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--emoji-test", default=EMOJI_TEST)
|
|
parser.add_argument("--annotations", default=ANNOTATIONS)
|
|
parser.add_argument("--derived", default=DERIVED)
|
|
parser.add_argument("--out", default=None)
|
|
parser.add_argument(
|
|
"--max-version",
|
|
default=MAX_VERSION,
|
|
help="newest emoji version to include (default %s)" % MAX_VERSION,
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="exit non-zero if the file on disk is not what we would write",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
out = args.out or os.path.join(root, "src", "emoji.zig")
|
|
|
|
version, rows, skipped = build(
|
|
read(args.emoji_test),
|
|
read(args.annotations),
|
|
read(args.derived),
|
|
args.max_version,
|
|
)
|
|
text = render(version, rows, args.max_version)
|
|
|
|
if args.check:
|
|
current = open(out, encoding="utf-8").read() if os.path.exists(out) else ""
|
|
if current != text:
|
|
print("%s is stale; re-run tools/gen-emoji.py" % out, file=sys.stderr)
|
|
return 1
|
|
print("%s is up to date (%d emoji)" % (out, len(rows)))
|
|
return 0
|
|
|
|
with open(out, "w", encoding="utf-8") as handle:
|
|
handle.write(text)
|
|
print("wrote %s: %d emoji from Unicode %s" % (out, len(rows), version))
|
|
for introduced, count in sorted(skipped.items()):
|
|
print(" held back %d from %s (newer than %s)" % (count, introduced, args.max_version))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|