2
0
mirror of https://github.com/iconify/iconify.git synced 2024-11-09 23:00:56 +00:00

chore(utils): restructure emoji code to handle sequences with custom properties, fix errors

This commit is contained in:
Vjacheslav Trushkin 2022-12-24 20:44:29 +02:00
parent 85c12955c0
commit e5dbd00cba
15 changed files with 1336 additions and 1208 deletions

View File

@ -142,10 +142,10 @@
"import": "./lib/emoji/test/components.mjs", "import": "./lib/emoji/test/components.mjs",
"types": "./lib/emoji/test/components.d.ts" "types": "./lib/emoji/test/components.d.ts"
}, },
"./lib/emoji/test/copy": { "./lib/emoji/test/missing": {
"require": "./lib/emoji/test/copy.cjs", "require": "./lib/emoji/test/missing.cjs",
"import": "./lib/emoji/test/copy.mjs", "import": "./lib/emoji/test/missing.mjs",
"types": "./lib/emoji/test/copy.d.ts" "types": "./lib/emoji/test/missing.d.ts"
}, },
"./lib/emoji/test/name": { "./lib/emoji/test/name": {
"require": "./lib/emoji/test/name.cjs", "require": "./lib/emoji/test/name.cjs",
@ -157,6 +157,16 @@
"import": "./lib/emoji/test/parse.mjs", "import": "./lib/emoji/test/parse.mjs",
"types": "./lib/emoji/test/parse.d.ts" "types": "./lib/emoji/test/parse.d.ts"
}, },
"./lib/emoji/test/tree": {
"require": "./lib/emoji/test/tree.cjs",
"import": "./lib/emoji/test/tree.mjs",
"types": "./lib/emoji/test/tree.d.ts"
},
"./lib/emoji/test/similar": {
"require": "./lib/emoji/test/similar.cjs",
"import": "./lib/emoji/test/similar.mjs",
"types": "./lib/emoji/test/similar.d.ts"
},
"./lib/emoji/test/variations": { "./lib/emoji/test/variations": {
"require": "./lib/emoji/test/variations.cjs", "require": "./lib/emoji/test/variations.cjs",
"import": "./lib/emoji/test/variations.mjs", "import": "./lib/emoji/test/variations.mjs",

View File

@ -92,13 +92,6 @@ export function joinEmojiSequences(
return results; return results;
} }
/**
* Remove variations
*/
export function removeEmojiVariations(sequence: number[]): number[] {
return sequence.filter((code) => code !== vs16Emoji);
}
/** /**
* Get unqualified sequence * Get unqualified sequence
*/ */

View File

@ -1,5 +1,6 @@
import { getSequenceFromEmojiStringOrKeyword } from '../cleanup'; import { getSequenceFromEmojiStringOrKeyword } from '../cleanup';
import { convertEmojiSequenceToUTF32 } from '../convert'; import { convertEmojiSequenceToUTF32 } from '../convert';
import type { EmojiTestData } from '../test/parse';
import { getQualifiedEmojiVariations } from '../test/variations'; import { getQualifiedEmojiVariations } from '../test/variations';
import { createEmojisTree, parseEmojiTree } from './tree'; import { createEmojisTree, parseEmojiTree } from './tree';
@ -38,7 +39,7 @@ export function createOptimisedRegexForEmojiSequences(
*/ */
export function createOptimisedRegex( export function createOptimisedRegex(
emojis: (string | number[])[], emojis: (string | number[])[],
testData?: number[][] testData?: EmojiTestData
): string { ): string {
// Convert to numbers // Convert to numbers
let sequences = emojis.map((item) => let sequences = emojis.map((item) =>
@ -48,7 +49,15 @@ export function createOptimisedRegex(
); );
// Add variations // Add variations
sequences = getQualifiedEmojiVariations(sequences, testData); // Temporary convert to object with 'sequence' property
sequences = getQualifiedEmojiVariations(
sequences.map((sequence) => {
return {
sequence,
};
}),
testData
).map((item) => item.sequence);
// Parse // Parse
return createOptimisedRegexForEmojiSequences(sequences); return createOptimisedRegexForEmojiSequences(sequences);

View File

@ -1,5 +1,6 @@
import { emojiComponents, EmojiComponentType } from '../data'; import { emojiComponents, EmojiComponentType } from '../data';
import type { EmojiSequenceToStringCallback, EmojiTestDataItem } from './parse'; import { getEmojiSequenceKeyword } from '../format';
import type { EmojiTestData, EmojiTestDataItem } from './parse';
export interface EmojiTestDataComponentsMap { export interface EmojiTestDataComponentsMap {
// Keywords // Keywords
@ -20,8 +21,7 @@ export interface EmojiTestDataComponentsMap {
* Map components from test data * Map components from test data
*/ */
export function mapEmojiTestDataComponents( export function mapEmojiTestDataComponents(
testSequences: Record<string, EmojiTestDataItem>, testSequences: EmojiTestData
convert: EmojiSequenceToStringCallback
): EmojiTestDataComponentsMap { ): EmojiTestDataComponentsMap {
const results: EmojiTestDataComponentsMap = { const results: EmojiTestDataComponentsMap = {
converted: new Map(), converted: new Map(),
@ -35,7 +35,7 @@ export function mapEmojiTestDataComponents(
const type = key as EmojiComponentType; const type = key as EmojiComponentType;
const range = emojiComponents[type]; const range = emojiComponents[type];
for (let number = range[0]; number <= range[1]; number++) { for (let number = range[0]; number <= range[1]; number++) {
const keyword = convert([number]); const keyword = getEmojiSequenceKeyword([number]);
const item = testSequences[keyword]; const item = testSequences[keyword];
if (!item) { if (!item) {
throw new Error( throw new Error(
@ -57,3 +57,87 @@ export function mapEmojiTestDataComponents(
return results; return results;
} }
/**
* Sequence with components
*/
export type EmojiSequenceWithComponents = (EmojiComponentType | number)[];
/**
* Convert to string
*/
export function emojiSequenceWithComponentsToString(
sequence: EmojiSequenceWithComponents
): string {
return sequence
.map((item) => (typeof item === 'number' ? item.toString(16) : item))
.join('-');
}
/**
* Entry in sequence
*/
export interface EmojiSequenceComponentEntry {
// Index in sequence
index: number;
// Component type
type: EmojiComponentType;
}
/**
* Find variations in sequence
*/
export function findEmojiComponentsInSequence(
sequence: number[]
): EmojiSequenceComponentEntry[] {
const components: EmojiSequenceComponentEntry[] = [];
for (let index = 0; index < sequence.length; index++) {
const code = sequence[index];
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
const range = emojiComponents[type];
if (code >= range[0] && code < range[1]) {
components.push({
index,
type,
});
break;
}
}
}
return components;
}
/**
* Component values
*/
export type EmojiSequenceComponentValues = Partial<
Record<EmojiComponentType, number[]>
>;
/**
* Replace components in sequence
*/
export function replaceEmojiComponentsInCombinedSequence(
sequence: EmojiSequenceWithComponents,
values: EmojiSequenceComponentValues
): number[] {
const indexes: Record<EmojiComponentType, number> = {
'hair-style': 0,
'skin-tone': 0,
};
return sequence.map((item) => {
if (typeof item === 'number') {
return item;
}
const index = indexes[item]++;
const list = values[item];
if (!list || !list.length) {
throw new Error(`Cannot replace ${item}: no valid values provided`);
}
return list[index >= list.length ? list.length - 1 : index];
});
}

View File

@ -1,275 +0,0 @@
import { getUnqualifiedEmojiSequence } from '../cleanup';
import { emojiComponents, EmojiComponentType } from '../data';
import { getEmojiSequenceKeyword } from '../format';
import { mapEmojiTestDataComponents } from './components';
import { EmojiComponentsMapItem, getEmojiComponentsMap } from './name';
import { EmojiTestDataItem, mapEmojiTestDataBySequence } from './parse';
type SequenceType = 'qualified' | 'unqualified';
interface SequenceData {
type: SequenceType;
sequence: number[];
key: string;
}
type Sequences = Record<SequenceType, SequenceData>;
type ComponentsIteration = Required<Record<EmojiComponentType, number[]>>;
/**
* Get components iteration
*/
function addToComponentsIteration(
components: ComponentsIteration,
attr: EmojiComponentType,
value: number
): ComponentsIteration {
const result: ComponentsIteration = {
'hair-style': components['hair-style'].slice(0),
'skin-tone': components['skin-tone'].slice(0),
};
result[attr].push(value);
return result;
}
/**
* Replace components with number in sequence
*/
function addComponentsToSequence(
sequence: (EmojiComponentType | number)[],
components: ComponentsIteration
): number[] {
const indexes: Required<Record<EmojiComponentType, number>> = {
'hair-style': 0,
'skin-tone': 0,
};
return sequence.map((value) => {
if (typeof value === 'number') {
return value;
}
const index = indexes[value]++;
return components[value][index];
});
}
/**
* Get sequence variations
*/
function getSequence(sequence: number[]): Sequences {
const qualified: SequenceData = {
type: 'qualified',
sequence,
key: getEmojiSequenceKeyword(sequence),
};
const unqualifiedSequence = getUnqualifiedEmojiSequence(sequence);
const unqualified: SequenceData =
unqualifiedSequence.length === sequence.length
? {
...qualified,
type: 'unqualified',
}
: {
type: 'unqualified',
sequence: unqualifiedSequence,
key: getEmojiSequenceKeyword(unqualifiedSequence),
};
return {
qualified,
unqualified,
};
}
/**
* Item to copy
*/
interface EmojiSequenceToCopy {
// Source: sequence and name
source: number[];
sourceName: string;
// Target: sequence and name
target: number[];
targetName: string;
}
/**
* Get sequences
*
* Returns map, where key is item to add, value is source
*/
export function getEmojisSequencesToCopy(
sequences: number[][],
testData: EmojiTestDataItem[]
): EmojiSequenceToCopy[] {
const results: EmojiSequenceToCopy[] = [];
// Prepare stuff
const componentsMap = mapEmojiTestDataComponents(
mapEmojiTestDataBySequence(testData, getEmojiSequenceKeyword),
getEmojiSequenceKeyword
);
const componentsMapItems = getEmojiComponentsMap(testData, componentsMap);
// Get all existing emojis
const existingItems = Object.create(null) as Record<string, number[]>;
const copiedItems = Object.create(null) as Record<string, number[]>;
sequences.forEach((sequence) => {
existingItems[getEmojiSequenceKeyword(sequence)] = sequence;
});
// Check if item exists
const itemExists = (sequence: Sequences): SequenceType | undefined => {
return existingItems[sequence.qualified.key]
? 'qualified'
: existingItems[sequence.unqualified.key]
? 'unqualified'
: void 0;
};
const itemWasCopied = (sequence: Sequences): SequenceType | undefined => {
return copiedItems[sequence.qualified.key]
? 'qualified'
: copiedItems[sequence.unqualified.key]
? 'unqualified'
: void 0;
};
// Copy item
const addToCopy = (
source: SequenceData,
sourceName: string,
target: SequenceData,
targetName: string
) => {
copiedItems[target.key] = target.sequence;
results.push({
source: source.sequence,
sourceName,
target: target.sequence,
targetName,
});
};
// Get name
const getName = (
item: EmojiComponentsMapItem,
components: ComponentsIteration
) => {
let name = item.name;
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
for (let i = 0; i < components[type].length; i++) {
const num = components[type][i];
const text = componentsMap.names.get(num) as string;
name = name.replace(`{${type}-${i}}`, text);
}
}
return name;
};
// Check item and its children
const checkItem = (
parentItem: EmojiComponentsMapItem,
parentSequence: SequenceData,
parentComponents: ComponentsIteration,
onlyIfExists = true
) => {
const children = parentItem.children;
if (!children) {
return;
}
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
if (children[type]) {
// Check emojis
const childItem = children[type];
const range = emojiComponents[type];
// Add each item in range
for (let num = range[0]; num < range[1]; num++) {
const components = addToComponentsIteration(
parentComponents,
type,
num
);
const sequence = addComponentsToSequence(
childItem.sequence,
components
);
const sequences = getSequence(sequence);
// Check if already exists
const existingSequence = itemExists(sequences);
if (existingSequence) {
// Already exists
checkItem(
childItem,
sequences[existingSequence],
components,
onlyIfExists
);
continue;
}
// Check if was copied
let copiedSequence = itemWasCopied(sequences);
if (copiedSequence && onlyIfExists) {
// Cannot parse nested items yet
continue;
}
// Copy
if (!copiedSequence) {
// Copy sequence
copiedSequence = parentSequence.type;
addToCopy(
parentSequence,
getName(parentItem, parentComponents),
sequences[copiedSequence],
getName(childItem, components)
);
}
// Check child items
checkItem(
childItem,
sequences[copiedSequence],
components,
onlyIfExists
);
}
}
}
};
// Check all items
componentsMapItems.forEach((mainItem) => {
const sequence = getSequence(mainItem.sequence as number[]);
const type = itemExists(sequence);
if (!type) {
// Base emoji is missing: nothing to do
return;
}
checkItem(
mainItem,
sequence[type],
{
'hair-style': [],
'skin-tone': [],
},
true
);
checkItem(
mainItem,
sequence[type],
{
'hair-style': [],
'skin-tone': [],
},
false
);
});
return results;
}

View File

@ -0,0 +1,154 @@
import { getUnqualifiedEmojiSequence } from '../cleanup';
import { emojiComponents, EmojiComponentType } from '../data';
import { getEmojiSequenceKeyword } from '../format';
import {
replaceEmojiComponentsInCombinedSequence,
EmojiSequenceComponentValues,
} from './components';
import type { EmojiComponentsTree, EmojiComponentsTreeItem } from './tree';
/**
* Base type to extend
*/
interface BaseSequenceItem {
sequence: number[];
// If present, will be set in value too
// String version of sequence without variation unicode
sequenceKey?: string;
}
/**
* Find missing emojis
*
* Result includes missing items, which are extended from items that needs to
* be copied. To identify which emojis to copy, source object should include
* something like `iconName` key that points to icon sequence represents.
*/
export function findMissingEmojis<T extends BaseSequenceItem>(
sequences: T[],
testDataTree: EmojiComponentsTree
): T[] {
const results: T[] = [];
const existingItems = Object.create(null) as Record<string, T>;
const copiedItems = Object.create(null) as Record<string, T>;
// Get all existing sequences
sequences.forEach((item) => {
const sequence = getUnqualifiedEmojiSequence(item.sequence);
const key = getEmojiSequenceKeyword(sequence);
if (
!existingItems[key] ||
// If multiple matches for same sequence exist, use longest version
existingItems[key].sequence.length < item.sequence.length
) {
existingItems[key] = item;
}
});
// Function to iterate sequences
const iterate = (
type: EmojiComponentType,
parentTree: EmojiComponentsTreeItem,
parentValues: Required<EmojiSequenceComponentValues>,
parentItem: T,
deep: boolean
) => {
const childTree = parentTree.children?.[type];
if (!childTree) {
return;
}
// Sequence exists
const range = emojiComponents[type];
for (let number = range[0]; number < range[1]; number++) {
// Create new values
const values: Required<EmojiSequenceComponentValues> = {
'hair-style': [...parentValues['hair-style']],
'skin-tone': [...parentValues['skin-tone']],
};
values[type].push(number);
// Generate sequence
const sequence = replaceEmojiComponentsInCombinedSequence(
childTree.item.sequence,
values
);
const key = getEmojiSequenceKeyword(
getUnqualifiedEmojiSequence(sequence)
);
// Get item
const oldItem = existingItems[key];
let item: T;
if (oldItem) {
// Exists
item = oldItem;
} else {
// Check if already created
item = copiedItems[key];
if (!item) {
// Create new item
item = {
...parentItem,
sequence,
};
if (item.sequenceKey) {
item.sequenceKey = key;
}
copiedItems[key] = item;
results.push(item);
}
}
// Check child elements
if (deep || oldItem) {
for (const key in values) {
iterate(
key as EmojiComponentType,
childTree,
values,
item,
deep
);
}
}
}
};
// Function to check tree item
const parse = (key: string, deep: boolean) => {
const treeItem = testDataTree[key];
const sequenceKey = treeItem.item.sequenceKey;
// Check if item actually exists
const rootItem = existingItems[sequenceKey];
if (!rootItem) {
return;
}
// Parse tree
const values: Required<EmojiSequenceComponentValues> = {
'skin-tone': [],
'hair-style': [],
};
for (const key in values) {
iterate(
key as EmojiComponentType,
treeItem,
values,
rootItem,
deep
);
}
};
// Shallow check first, then full check
for (const key in testDataTree) {
parse(key, false);
parse(key, true);
}
return results;
}

View File

@ -1,19 +1,14 @@
import { emojiComponents, EmojiComponentType, vs16Emoji } from '../data'; import { emojiComponents, EmojiComponentType } from '../data';
import { getEmojiSequenceKeyword } from '../format'; import type {
import { EmojiSequenceComponentEntry,
EmojiTestDataComponentsMap, EmojiTestDataComponentsMap,
mapEmojiTestDataComponents,
} from './components'; } from './components';
import { EmojiTestDataItem, mapEmojiTestDataBySequence } from './parse';
interface EmojiNameVariation {
// Index in sequence
index: number;
// Component type
type: EmojiComponentType;
}
/**
* Split emoji name in base name and variations
*
* Variations are also split in strings and emoji components with indexes pointing to sequence
*/
export interface SplitEmojiName { export interface SplitEmojiName {
// Base name // Base name
base: string; base: string;
@ -22,7 +17,7 @@ export interface SplitEmojiName {
key: string; key: string;
// Variations // Variations
variations?: (string | EmojiNameVariation)[]; variations?: (string | EmojiSequenceComponentEntry)[];
// Number of components // Number of components
components?: number; components?: number;
@ -55,293 +50,52 @@ export function splitEmojiNameVariations(
} }
// Get variations // Get variations
let startIndex = 0; const variations: (string | EmojiSequenceComponentEntry)[] = parts
let components = 0;
const keyParts: string[] = [];
const variations = parts
.join(nameSplit) .join(nameSplit)
.split(variationSplit) .split(variationSplit)
.map((text) => { .filter((text) => {
const type = componentsData.types[text]; const type = componentsData.types[text];
if (!type) { if (!type) {
// Not a component // Not a component
if (!ignoredVariations.has(text)) { return !ignoredVariations.has(text);
keyParts.push(text);
}
return text;
} }
// Component // Component
const range = emojiComponents[type]; return false;
while (startIndex < sequence.length) {
const num = sequence[startIndex];
startIndex++;
if (num >= range[0] && num <= range[1]) {
// Got range match
components++;
return {
index: startIndex - 1,
type,
};
}
}
// Ran out of sequence
throw new Error(
`Cannot find variation in sequence for "${name}", [${sequence.join(
' '
)}]`
);
}); });
const key = const key =
base + base +
(keyParts.length ? nameSplit + keyParts.join(variationSplit) : ''); (variations.length ? nameSplit + variations.join(variationSplit) : '');
const result: SplitEmojiName = {
return {
base, base,
key, key,
variations,
components,
};
}
/**
* Merge component types
*/
function mergeComponentTypes(value: EmojiComponentType[]) {
return '[' + value.join(',') + ']';
}
type ComponentsCount = Required<Record<EmojiComponentType, number>>;
function mergeComponentsCount(value: ComponentsCount) {
const keys: EmojiComponentType[] = [];
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
for (let i = 0; i < value[type]; i++) {
keys.push(type);
}
}
return keys.length ? mergeComponentTypes(keys) : '';
}
/**
* Map item
*/
type EmojiComponentsMapItemSequence = (EmojiComponentType | number)[];
export interface EmojiComponentsMapItem {
// Name, with `{skin-tone-1}` (type + index) placeholders
name: string;
// Sequence
sequence: EmojiComponentsMapItemSequence;
// Child element(s)
children?: Record<EmojiComponentType, EmojiComponentsMapItem>;
}
/**
* Get map of emoji components
*
* Result includes emoji sequences with largest number of characters (usually fully-qualified)
* Only sequences with components are returned
*/
export function getEmojiComponentsMap(
testData: EmojiTestDataItem[],
componentsMap?: EmojiTestDataComponentsMap
): EmojiComponentsMapItem[] {
// Prepare stuff
const components =
componentsMap ||
mapEmojiTestDataComponents(
mapEmojiTestDataBySequence(testData, getEmojiSequenceKeyword),
getEmojiSequenceKeyword
);
// Function to clean sequence
const cleanSequence = (sequence: number[]): string => {
return getEmojiSequenceKeyword(
sequence.filter(
(num) => num !== vs16Emoji && !components.converted.has(num)
)
);
}; };
// Map all items // Check sequence for variations
interface SplitListItem { let components = 0;
item: EmojiTestDataItem; for (let index = 0; index < sequence.length; index++) {
split: SplitEmojiName; const num = sequence[index];
components: ComponentsCount; for (const key in emojiComponents) {
} const type = key as EmojiComponentType;
type SplitList = Record<string, SplitListItem>; const range = emojiComponents[type];
const splitData = Object.create(null) as Record<string, SplitList>; if (num >= range[0] && num < range[1]) {
const defaultSplitDataKey = 'default'; // Within range
variations.push({
testData.forEach((item) => { index,
// Split it type,
const split = splitEmojiNameVariations( });
item.name, components++;
item.sequence,
components
);
const parent =
splitData[split.key] ||
(splitData[split.key] = Object.create(null) as SplitList);
// Create unique key based on component types
let sequenceKey = defaultSplitDataKey;
const itemComponents: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
if (split.components) {
split.variations?.forEach((item) => {
if (typeof item !== 'string') {
itemComponents[item.type]++;
}
});
sequenceKey =
mergeComponentsCount(itemComponents) || defaultSplitDataKey;
}
// Get item if already exists
const prevItem = parent[sequenceKey];
if (!prevItem) {
parent[sequenceKey] = {
item,
split,
components: itemComponents,
};
return;
}
if (
cleanSequence(prevItem.item.sequence) !==
cleanSequence(item.sequence)
) {
// console.log(prevItem.item);
// console.log(item);
throw new Error(`Mismatched items with same key: ${sequenceKey}`);
}
if (item.sequence.length > prevItem.item.sequence.length) {
// Keep longer sequence
parent[sequenceKey] = {
item,
split,
components: itemComponents,
};
}
});
// Parse all items
const results: EmojiComponentsMapItem[] = [];
for (const key in splitData) {
const items = splitData[key];
// Function to get item
const getItem = (
components: ComponentsCount
): EmojiComponentsMapItem | undefined => {
const key = mergeComponentsCount(components) || defaultSplitDataKey;
const item = items[key];
if (!item) {
return;
}
const split = item.split;
const variations = split.variations;
// Get sequence
const sequence = item.item.sequence.slice(
0
) as EmojiComponentsMapItemSequence;
variations?.forEach((chunk) => {
if (typeof chunk === 'string') {
return;
}
sequence[chunk.index] = chunk.type;
});
// Get name
const counter: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
const nameVariations = variations?.map((chunk) => {
if (typeof chunk === 'string') {
return chunk;
}
const count = counter[chunk.type]++;
if (components[chunk.type] < count) {
throw new Error('Bad variations order');
}
return `{${chunk.type}-${count}}`;
});
const name =
split.base +
(nameVariations?.length
? nameSplit + nameVariations.join(variationSplit)
: '');
return {
name,
sequence,
};
};
const checkChildren = (
parent: EmojiComponentsMapItem,
components: ComponentsCount
): boolean => {
// Attempt to add each type
let found = false;
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
// Find child item
const childComponents = {
...components,
};
childComponents[type]++;
const childItem = getItem(childComponents);
// Get sequence for child item
if (childItem) {
found = true;
// Add child item, check its children
const children =
parent.children ||
(parent.children = {} as Record<
EmojiComponentType,
EmojiComponentsMapItem
>);
children[type] = childItem;
checkChildren(childItem, childComponents);
}
}
return found;
};
// Get main item
const mainItem = getItem({
'hair-style': 0,
'skin-tone': 0,
});
if (mainItem) {
if (
checkChildren(mainItem, {
'hair-style': 0,
'skin-tone': 0,
})
) {
// Found item with children
results.push(mainItem);
} }
} }
} }
return results; if (variations.length) {
result.variations = variations;
}
if (components) {
result.components = components;
}
return result;
} }

View File

@ -21,21 +21,21 @@ const allowedStatus: Set<EmojiStatus> = new Set([
]); ]);
/** /**
* Callback for converting sequence to string * Base item
*/ */
export type EmojiSequenceToStringCallback = (value: number[]) => string; export interface BaseEmojiTestDataItem {
/**
* Test data item
*/
export interface EmojiTestDataItem {
// Group and subgroup // Group and subgroup
group: string; group: string;
subgroup: string; subgroup: string;
// Code points as string, lower case, dash separated // Version when emoji was added
code: string; version: string;
}
/**
* Test data item
*/
export interface EmojiTestDataItem extends BaseEmojiTestDataItem {
// Code points as numbers, UTF-32 // Code points as numbers, UTF-32
sequence: number[]; sequence: number[];
@ -45,20 +45,62 @@ export interface EmojiTestDataItem {
// Status // Status
status: EmojiStatus; status: EmojiStatus;
// Version when emoji was added
version: string;
// Emoji name // Emoji name
name: string; name: string;
} }
export type EmojiTestData = Record<string, EmojiTestDataItem>;
/**
* Get qualified variations from parsed test file
*
* Key is unqualified emoji, value is longest fully qualified emoji
*/
function getQualifiedTestData(data: EmojiTestData): EmojiTestData {
const results = Object.create(null) as EmojiTestData;
for (const key in data) {
const item = data[key];
const sequence = getUnqualifiedEmojiSequence(item.sequence);
const shortKey = getEmojiSequenceKeyword(sequence);
// Check if values mismatch, set results to longest value
if (
!results[shortKey] ||
results[shortKey].sequence.length < sequence.length
) {
results[shortKey] = item;
}
}
return results;
}
/** /**
* Get all emoji sequences from test file * Get all emoji sequences from test file
* *
* Returns all emojis as UTF-32 sequences * Returns all emojis as UTF-32 sequences, where:
* key = unqualified sequence (without \uFE0F)
* value = qualified sequence (with \uFE0F)
*
* Duplicate items that have different versions with and without \uFE0F are
* listed only once, with unqualified sequence as key and longest possible
* qualified sequence as value
*
* Example of 3 identical entries:
* '1F441 FE0F 200D 1F5E8 FE0F'
* '1F441 200D 1F5E8 FE0F'
* '1F441 FE0F 200D 1F5E8'
* '1F441 200D 1F5E8'
*
* Out of these entries, only one item will be returned with:
* key = '1f441-200d-1f5e8' (converted to lower case, separated with dash)
* value.sequence = [0x1F441, 0xFE0F, 0x200D, 0x1F5E8, 0xFE0F]
* value.status = 'fully-qualified'
* other properties in value are identical for all versions
*/ */
export function parseEmojiTestFile(data: string): EmojiTestDataItem[] { export function parseEmojiTestFile(data: string): EmojiTestData {
const results: EmojiTestDataItem[] = []; const results = Object.create(null) as EmojiTestData;
let group: string | undefined; let group: string | undefined;
let subgroup: string | undefined; let subgroup: string | undefined;
@ -106,11 +148,8 @@ export function parseEmojiTestFile(data: string): EmojiTestDataItem[] {
return; return;
} }
const code = firstChunkParts[0] const code = firstChunkParts[0].trim();
.trim() if (!code || !code.match(/^[A-F0-9]+[A-F0-9\s]*[A-F0-9]+$/)) {
.replace(/\s+/g, '-')
.toLowerCase();
if (!code || !code.match(/^[a-f0-9]+[a-f0-9-]*[a-f0-9]+$/)) {
return; return;
} }
@ -133,87 +172,24 @@ export function parseEmojiTestFile(data: string): EmojiTestDataItem[] {
} }
const name = secondChunkParts.join(' '); const name = secondChunkParts.join(' ');
// Get sequence and convert it to cleaned up string
const sequence = getEmojiSequenceFromString(code);
const key = getEmojiSequenceKeyword(sequence);
// Add item // Add item
results.push({ if (results[key]) {
throw new Error(`Duplicate entry for "${code}"`);
}
results[key] = {
group, group,
subgroup, subgroup,
code, sequence,
sequence: getEmojiSequenceFromString(code),
emoji, emoji,
status, status,
version, version,
name, name,
}); };
}); });
return results; return getQualifiedTestData(results);
}
/**
* Get qualified variations from parsed test file
*
* Key is unqualified emoji, value is longest fully qualified emoji
*/
export function getQualifiedEmojiSequencesMap(
sequences: number[][]
): Map<number[], number[]>;
export function getQualifiedEmojiSequencesMap(
sequences: number[][],
toString: (value: number[]) => string
): Record<string, string>;
export function getQualifiedEmojiSequencesMap(
sequences: number[][],
toString?: (value: number[]) => string
): Map<number[], number[]> | Record<string, string> {
const convert = toString || getEmojiSequenceKeyword;
const results = Object.create(null) as Record<string, string>;
for (let i = 0; i < sequences.length; i++) {
const value = convert(sequences[i]);
const unqualified = convert(getUnqualifiedEmojiSequence(sequences[i]));
// Check if values mismatch, set results to longest value
if (
!results[unqualified] ||
results[unqualified].length < value.length
) {
results[unqualified] = value;
}
}
// Return
if (toString) {
return results;
}
const map: Map<number[], number[]> = new Map();
for (const key in results) {
const value = results[key];
map.set(
getEmojiSequenceFromString(key),
getEmojiSequenceFromString(value)
);
}
return map;
}
/**
* Map data by sequence
*/
export function mapEmojiTestDataBySequence(
testData: EmojiTestDataItem[],
convert: EmojiSequenceToStringCallback
): Record<string, EmojiTestDataItem> {
const testSequences = Object.create(null) as Record<
string,
EmojiTestDataItem
>;
for (let i = 0; i < testData.length; i++) {
const item = testData[i];
const keyword = convert(item.sequence);
if (testSequences[keyword]) {
throw new Error(`Duplicate entries for "${keyword}"`);
}
testSequences[keyword] = item;
}
return testSequences;
} }

View File

@ -0,0 +1,88 @@
import { vs16Emoji } from '../data';
import {
EmojiSequenceWithComponents,
emojiSequenceWithComponentsToString,
EmojiTestDataComponentsMap,
mapEmojiTestDataComponents,
} from './components';
import { SplitEmojiName, splitEmojiNameVariations } from './name';
import type {
BaseEmojiTestDataItem,
EmojiTestData,
EmojiTestDataItem,
} from './parse';
/**
* Similar test data items as one item
*/
export interface CombinedEmojiTestDataItem extends BaseEmojiTestDataItem {
// Name, split
name: SplitEmojiName;
// Sequence without variations, but with '{skin-tone}'
sequenceKey: string;
// Sequence with components
sequence: EmojiSequenceWithComponents;
}
export type SimilarEmojiTestData = Record<string, CombinedEmojiTestDataItem>;
/**
* Find components in item, generate CombinedEmojiTestDataItem
*/
export function findComponentsInEmojiTestItem(
item: EmojiTestDataItem,
componentsData: EmojiTestDataComponentsMap
): CombinedEmojiTestDataItem {
// Split name
const name = splitEmojiNameVariations(
item.name,
item.sequence,
componentsData
);
// Update sequence
const sequence = [...item.sequence] as EmojiSequenceWithComponents;
name.variations?.forEach((item) => {
if (typeof item !== 'string') {
sequence[item.index] = item.type;
}
});
// Generate new key based on sequence
const sequenceKey = emojiSequenceWithComponentsToString(
sequence.filter((code) => code !== vs16Emoji)
);
return {
...item,
name,
sequenceKey,
sequence,
};
}
/**
* Combine similar items in one iteratable item
*/
export function combineSimilarEmojiTestData(
data: EmojiTestData,
componentsData?: EmojiTestDataComponentsMap
): SimilarEmojiTestData {
const results = Object.create(null) as SimilarEmojiTestData;
componentsData = componentsData || mapEmojiTestDataComponents(data);
for (const key in data) {
const sourceItem = data[key];
if (sourceItem.status !== 'component') {
const item = findComponentsInEmojiTestItem(
sourceItem,
componentsData
);
results[item.sequenceKey] = item;
}
}
return results;
}

View File

@ -0,0 +1,199 @@
import { emojiComponents, EmojiComponentType } from '../data';
import type {
SimilarEmojiTestData,
CombinedEmojiTestDataItem,
} from './similar';
/**
* List of components
*/
type ComponentsCount = Required<Record<EmojiComponentType, number>>;
/**
* Extended tree item
*/
interface TreeSplitEmojiTestDataItem extends CombinedEmojiTestDataItem {
// Components
components: ComponentsCount;
// Components, stringified
componentsKey: string;
}
/**
* Tree item
*/
export interface EmojiComponentsTreeItem {
// Item
item: TreeSplitEmojiTestDataItem;
// Child element(s)
children?: Record<EmojiComponentType, EmojiComponentsTreeItem>;
}
export type EmojiComponentsTree = Record<string, EmojiComponentsTreeItem>;
/**
* Merge types for unique key
*/
function mergeComponentTypes(value: EmojiComponentType[]) {
return '[' + value.join(',') + ']';
}
/**
* Merge count for unique key
*/
function mergeComponentsCount(value: ComponentsCount): string {
const keys: EmojiComponentType[] = [];
for (const key in emojiComponents) {
const type = key as EmojiComponentType;
for (let i = 0; i < value[type]; i++) {
keys.push(type);
}
}
return keys.length ? mergeComponentTypes(keys) : '';
}
/**
* Group data
*/
interface GroupItem {
item: TreeSplitEmojiTestDataItem;
parsed?: true;
}
type GroupItems = Record<string, GroupItem>;
/**
* Get item from group
*/
function getGroupItem(
items: GroupItems,
components: ComponentsCount
): TreeSplitEmojiTestDataItem | undefined {
const key = mergeComponentsCount(components);
const item = items[key];
if (item) {
item.parsed = true;
return item.item;
}
}
/**
* Convert test data to dependencies tree, based on components
*/
export function getEmojiTestDataTree(
data: SimilarEmojiTestData
): EmojiComponentsTree {
// Group items by base name
const groups = Object.create(null) as Record<string, GroupItems>;
for (const key in data) {
const item = data[key];
const text = item.name.key;
const parent = groups[text] || (groups[text] = {} as GroupItems);
// Generate key
const components: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
item.sequence.forEach((value) => {
if (typeof value !== 'number') {
components[value]++;
}
});
const componentsKey = mergeComponentsCount(components);
if (parent[componentsKey]) {
throw new Error(`Duplicate components tree item for "${text}"`);
}
parent[componentsKey] = {
item: {
...item,
components,
componentsKey,
},
};
}
// Sort items
const results = Object.create(null) as EmojiComponentsTree;
for (const key in groups) {
const items = groups[key];
const check = (
parent: EmojiComponentsTreeItem,
parentComponents: EmojiComponentType[],
type: EmojiComponentType
): true | undefined => {
const item = parse(parentComponents, [type]);
if (item) {
const children =
parent.children ||
(parent.children =
{} as Required<EmojiComponentsTreeItem>['children']);
children[type] = item;
return true;
}
};
const parse = (
parentComponents: EmojiComponentType[],
newComponents: EmojiComponentType[]
): EmojiComponentsTreeItem | undefined => {
// Merge parameters
const components: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
const componentsList = parentComponents.concat(newComponents);
componentsList.forEach((type) => {
components[type]++;
});
// Get item
let item = getGroupItem(items, components);
if (
!item &&
newComponents.length === 1 &&
newComponents[0] === 'skin-tone'
) {
// Attempt double skin tone
const doubleComponents = {
...components,
};
doubleComponents['skin-tone']++;
item = getGroupItem(items, doubleComponents);
}
if (item) {
// Check child items
const result: EmojiComponentsTreeItem = {
item,
};
// Try adding children
for (const key in emojiComponents) {
check(result, componentsList, key as EmojiComponentType);
}
return result;
}
};
const root = parse([], []);
if (!root) {
throw new Error(`Cannot find parent item for "${key}"`);
}
// Make sure all child items are checked
for (const itemsKey in items) {
if (!items[itemsKey].parsed) {
throw new Error(`Error generating tree for "${key}"`);
}
}
// Make sure root is not empty
if (root.children) {
results[key] = root;
}
}
return results;
}

View File

@ -1,13 +1,12 @@
import { import {
getEmojiSequenceFromString, getUnqualifiedEmojiSequence,
joinEmojiSequences, joinEmojiSequences,
removeEmojiVariations,
splitEmojiSequences, splitEmojiSequences,
} from '../cleanup'; } from '../cleanup';
import { convertEmojiSequenceToUTF32 } from '../convert'; import { convertEmojiSequenceToUTF32 } from '../convert';
import { keycapEmoji, vs16Emoji } from '../data'; import { keycapEmoji, vs16Emoji } from '../data';
import { getEmojiSequenceKeyword } from '../format'; import { getEmojiSequenceKeyword } from '../format';
import { EmojiTestDataItem, getQualifiedEmojiSequencesMap } from './parse'; import type { EmojiTestData } from './parse';
/** /**
* Get qualified sequence, adding optional `FE0F` wherever it might exist * Get qualified sequence, adding optional `FE0F` wherever it might exist
@ -36,6 +35,17 @@ export function guessQualifiedEmojiSequence(sequence: number[]): number[] {
return joinEmojiSequences(split); return joinEmojiSequences(split);
} }
/**
* Base type to extend
*/
interface BaseSequenceItem {
sequence: number[];
// If present, will be set in value too
// String version of sequence without variation unicode
sequenceKey?: string;
}
/** /**
* Get qualified variations for emojis * Get qualified variations for emojis
* *
@ -45,48 +55,54 @@ export function guessQualifiedEmojiSequence(sequence: number[]): number[] {
* If missing or emoji is missing in test data, `FE0F` is added to every single code emoji. * If missing or emoji is missing in test data, `FE0F` is added to every single code emoji.
* It can also be an array of sequences. * It can also be an array of sequences.
*/ */
export function getQualifiedEmojiVariations(
sequences: number[][], export function getQualifiedEmojiVariation<T extends BaseSequenceItem>(
testData?: (number[] | EmojiTestDataItem)[] item: T,
): number[][]; testData?: EmojiTestData
export function getQualifiedEmojiVariations( ): T {
sequences: number[][], // Convert to UTF-32, get unqualified sequence
testData: (number[] | EmojiTestDataItem)[], const unqualifiedSequence = getUnqualifiedEmojiSequence(
toString: (value: number[]) => string convertEmojiSequenceToUTF32(item.sequence)
): string[];
export function getQualifiedEmojiVariations(
sequences: number[][],
testData: (number[] | EmojiTestDataItem)[] = [],
toString?: (value: number[]) => string
): number[][] | string[] {
const convert = toString || getEmojiSequenceKeyword;
const testSequences = testData.map((item) =>
item instanceof Array ? item : item.sequence
); );
// Map test data // Check test data. Key is unqualified sequence
const testDataMap = getQualifiedEmojiSequencesMap(testSequences, convert); const key = getEmojiSequenceKeyword(unqualifiedSequence);
const testDataItem = testData?.[key];
// Parse all sequences const result: T = {
const set: Set<string> = new Set(); ...item,
sequence: testDataItem
sequences.forEach((sequence) => { ? testDataItem.sequence
// Convert to UTF-32, remove variations : guessQualifiedEmojiSequence(unqualifiedSequence),
const convertedSequence = convertEmojiSequenceToUTF32(sequence); };
const cleanSequence = removeEmojiVariations(convertedSequence); if (result.sequenceKey) {
result.sequenceKey = key;
// Check test data }
const mapKey = convert(cleanSequence); return result;
if (testDataMap[mapKey]) { }
// Got item from test data
set.add(testDataMap[mapKey]); /**
return; * Get qualified emoji variations for set of emojis, ignoring duplicate entries
} */
export function getQualifiedEmojiVariations<T extends BaseSequenceItem>(
// Not in test data: guess variations items: T[],
set.add(convert(guessQualifiedEmojiSequence(cleanSequence))); testData?: EmojiTestData
}); ): T[] {
// Parse all sequences
const results = Array.from(set); const results = Object.create(null) as Record<string, T>;
return toString ? results : results.map(getEmojiSequenceFromString);
for (let i = 0; i < items.length; i++) {
const result = getQualifiedEmojiVariation(items[i], testData);
const key = getEmojiSequenceKeyword(
getUnqualifiedEmojiSequence(result.sequence)
);
if (
!results[key] ||
results[key].sequence.length < result.sequence.length
) {
results[key] = result;
}
}
return Object.values(results);
} }

View File

@ -87,7 +87,6 @@ export { loadIcon } from './loader/loader';
export { export {
getEmojiSequenceFromString, getEmojiSequenceFromString,
getUnqualifiedEmojiSequence, getUnqualifiedEmojiSequence,
removeEmojiVariations,
} from './emoji/cleanup'; } from './emoji/cleanup';
export { export {
getEmojiCodePoint, getEmojiCodePoint,
@ -103,12 +102,9 @@ export {
getEmojiSequenceString, getEmojiSequenceString,
getEmojiSequenceKeyword, getEmojiSequenceKeyword,
} from './emoji/format'; } from './emoji/format';
export { export { parseEmojiTestFile } from './emoji/test/parse';
parseEmojiTestFile,
getQualifiedEmojiSequencesMap,
} from './emoji/test/parse';
export { getQualifiedEmojiVariations } from './emoji/test/variations'; export { getQualifiedEmojiVariations } from './emoji/test/variations';
export { getEmojisSequencesToCopy } from './emoji/test/copy'; // export { getEmojisSequencesToCopy } from './emoji/test/copy';
export { export {
createOptimisedRegex, createOptimisedRegex,
createOptimisedRegexForEmojiSequences, createOptimisedRegexForEmojiSequences,

View File

@ -2,7 +2,7 @@ import { convertEmojiSequenceToUTF32 } from '../lib/emoji/convert';
import { import {
getEmojiSequenceFromString, getEmojiSequenceFromString,
joinEmojiSequences, joinEmojiSequences,
removeEmojiVariations, getUnqualifiedEmojiSequence,
splitEmojiSequences, splitEmojiSequences,
} from '../lib/emoji/cleanup'; } from '../lib/emoji/cleanup';
@ -37,7 +37,7 @@ describe('Testing formatting emoji cleanup', () => {
expect(joinEmojiSequences(split)).toEqual(sequence); expect(joinEmojiSequences(split)).toEqual(sequence);
// Remove variations // Remove variations
expect(removeEmojiVariations(sequence)).toEqual([ expect(getUnqualifiedEmojiSequence(sequence)).toEqual([
0x1f441, 0x200d, 0x1f5e8, 0x1f441, 0x200d, 0x1f5e8,
]); ]);
}); });
@ -63,6 +63,6 @@ describe('Testing formatting emoji cleanup', () => {
expect(joinEmojiSequences(split)).toEqual(sequence); expect(joinEmojiSequences(split)).toEqual(sequence);
// Remove variations (does nothing for this sequence) // Remove variations (does nothing for this sequence)
expect(removeEmojiVariations(sequence)).toEqual(sequence); expect(getUnqualifiedEmojiSequence(sequence)).toEqual(sequence);
}); });
}); });

View File

@ -2,10 +2,7 @@ import { readFile, writeFile, unlink } from 'node:fs/promises';
import { emojiVersion } from '../lib/emoji/data'; import { emojiVersion } from '../lib/emoji/data';
import { getEmojiSequenceFromString } from '../lib/emoji/cleanup'; import { getEmojiSequenceFromString } from '../lib/emoji/cleanup';
import { getEmojiSequenceString } from '../lib/emoji/format'; import { getEmojiSequenceString } from '../lib/emoji/format';
import { import { parseEmojiTestFile } from '../lib/emoji/test/parse';
getQualifiedEmojiSequencesMap,
parseEmojiTestFile,
} from '../lib/emoji/test/parse';
import { getQualifiedEmojiVariations } from '../lib/emoji/test/variations'; import { getQualifiedEmojiVariations } from '../lib/emoji/test/variations';
describe('Qualified variations of emoji sequences', () => { describe('Qualified variations of emoji sequences', () => {
@ -61,11 +58,18 @@ describe('Qualified variations of emoji sequences', () => {
// mix of simple and complex, with and without variation // mix of simple and complex, with and without variation
'1F9D7 1F3FE 200D 2640 FE0F', '1F9D7 1F3FE 200D 2640 FE0F',
'1F9D7 1F3FF 200D 2642 ', '1F9D7 1F3FF 200D 2642 ',
].map(getEmojiSequenceFromString); ].map((source) => {
const sequence = getEmojiSequenceFromString(source);
return {
source,
sequence,
};
});
const results = getQualifiedEmojiVariations(sequences); const results = getQualifiedEmojiVariations(sequences);
expect( expect(
results.map((sequence) => results.map((item) =>
getEmojiSequenceString(sequence, { getEmojiSequenceString(item.sequence, {
separator: ' ', separator: ' ',
case: 'upper', case: 'upper',
format: 'utf-32', format: 'utf-32',
@ -98,23 +102,17 @@ describe('Qualified variations of emoji sequences', () => {
console.warn('Test skipped: test data is not available'); console.warn('Test skipped: test data is not available');
return; return;
} }
const testData = parseEmojiTestFile(data); const testData = parseEmojiTestFile(data);
const testDataSequences = testData.map((item) => item.sequence);
// Make sure testData contains both fully-qualified and unqualified emojis // Make sure testData keys contain only unqualified emojis
const testDataStrings = new Set(testData.map((item) => item.code)); const testDataStrings = new Set(Object.keys(testData));
expect(testDataStrings.has('1f600')).toBe(true); expect(testDataStrings.has('1f600')).toBe(true);
expect(testDataStrings.has('263a')).toBe(true); expect(testDataStrings.has('263a')).toBe(true);
expect(testDataStrings.has('263a-fe0f')).toBe(true); expect(testDataStrings.has('263a-fe0f')).toBe(false);
// Test getQualifiedEmojiSequencesMap // Make sure values contain qualified emojis
const unqualifiedTest = getQualifiedEmojiSequencesMap( expect(testData['1f600'].sequence).toEqual([0x1f600]);
testDataSequences, expect(testData['263a'].sequence).toEqual([0x263a, 0xfe0f]);
getEmojiSequenceString
);
expect(unqualifiedTest['1f600']).toBe('1f600');
expect(unqualifiedTest['263a']).toBe('263a-fe0f');
// Sequences to test // Sequences to test
const sequences = [ const sequences = [
@ -131,17 +129,20 @@ describe('Qualified variations of emoji sequences', () => {
// complex emoji, exists in file // complex emoji, exists in file
'1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FB', '1F9D1 1F3FE 200D 2764 200D 1F9D1 1F3FB',
// simple emoji, not in test file // simple emoji, not in test file
'1234', '25F0',
// fake keycap, not in test file // fake keycap, not in test file
'2345 20E3 200D 1235', '2345 20E3 200D 1235',
].map(getEmojiSequenceFromString); ].map((source) => {
const results = getQualifiedEmojiVariations( const sequence = getEmojiSequenceFromString(source);
sequences, return {
testDataSequences source,
); sequence,
};
});
const results = getQualifiedEmojiVariations(sequences, testData);
expect( expect(
results.map((sequence) => results.map((item) =>
getEmojiSequenceString(sequence, { getEmojiSequenceString(item.sequence, {
separator: ' ', separator: ' ',
case: 'upper', case: 'upper',
format: 'utf-32', format: 'utf-32',
@ -162,7 +163,7 @@ describe('Qualified variations of emoji sequences', () => {
// complex emoji, exists in file // complex emoji, exists in file
'1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FB', '1F9D1 1F3FE 200D 2764 FE0F 200D 1F9D1 1F3FB',
// simple emoji, not in test file // simple emoji, not in test file
'1234 FE0F', '25F0 FE0F',
// fake keycap, not in test file // fake keycap, not in test file
'2345 FE0F 20E3 200D 1235 FE0F', '2345 FE0F 20E3 200D 1235 FE0F',
]); ]);

File diff suppressed because it is too large Load Diff