2
0
mirror of https://github.com/iconify/iconify.git synced 2025-01-22 14:48:24 +00:00

feat(utils): function to find missing emojis, fixes for emoji functions

This commit is contained in:
Vjacheslav Trushkin 2022-12-16 10:48:17 +02:00
parent c7b6dc73f5
commit 37e382d989
9 changed files with 455 additions and 43 deletions

View File

@ -3,7 +3,7 @@
"type": "module", "type": "module",
"description": "Common functions for working with Iconify icon sets used by various packages.", "description": "Common functions for working with Iconify icon sets used by various packages.",
"author": "Vjacheslav Trushkin", "author": "Vjacheslav Trushkin",
"version": "2.0.3", "version": "2.0.4",
"license": "MIT", "license": "MIT",
"bugs": "https://github.com/iconify/iconify/issues", "bugs": "https://github.com/iconify/iconify/issues",
"homepage": "https://iconify.design/", "homepage": "https://iconify.design/",
@ -142,6 +142,11 @@
"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": {
"require": "./lib/emoji/test/copy.cjs",
"import": "./lib/emoji/test/copy.mjs",
"types": "./lib/emoji/test/copy.d.ts"
},
"./lib/emoji/test/name": { "./lib/emoji/test/name": {
"require": "./lib/emoji/test/name.cjs", "require": "./lib/emoji/test/name.cjs",
"import": "./lib/emoji/test/name.mjs", "import": "./lib/emoji/test/name.mjs",

View File

@ -19,10 +19,10 @@ export const keycapEmoji = 0x20e3;
export type EmojiComponentType = 'skin-tone' | 'hair-style'; export type EmojiComponentType = 'skin-tone' | 'hair-style';
type Range = [number, number]; type Range = [number, number];
export const emojiComponents: Record<EmojiComponentType, Range> = { export const emojiComponents: Record<EmojiComponentType, Range> = {
// Skin tones
'skin-tone': [0x1f3fb, 0x1f400],
// Hair styles // Hair styles
'hair-style': [0x1f9b0, 0x1f9b4], 'hair-style': [0x1f9b0, 0x1f9b4],
// Skin tones
'skin-tone': [0x1f3fb, 0x1f400],
}; };
/** /**

View File

@ -1,6 +1,6 @@
import { getEmojiSequenceFromString } from '../cleanup'; import { getEmojiSequenceFromString } from '../cleanup';
import { convertEmojiSequenceToUTF32 } from '../convert'; import { convertEmojiSequenceToUTF32 } from '../convert';
import { addQualifiedEmojiVariations } from '../test/variations'; import { getQualifiedEmojiVariations } from '../test/variations';
import { createEmojisTree, parseEmojiTree } from './tree'; import { createEmojisTree, parseEmojiTree } from './tree';
/** /**
@ -46,7 +46,7 @@ export function createOptimisedRegex(
); );
// Add variations // Add variations
sequences = addQualifiedEmojiVariations(sequences, testData); sequences = getQualifiedEmojiVariations(sequences, testData);
// Parse // Parse
return createOptimisedRegexForEmojiSequences(sequences); return createOptimisedRegexForEmojiSequences(sequences);

View File

@ -0,0 +1,275 @@
import { getUnqualifiedEmojiSequence } from '../cleanup';
import { emojiComponents, EmojiComponentType } from '../data';
import { getEmojiSequenceString } 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: getEmojiSequenceString(sequence),
};
const unqualifiedSequence = getUnqualifiedEmojiSequence(sequence);
const unqualified: SequenceData =
unqualifiedSequence.length === sequence.length
? {
...qualified,
type: 'unqualified',
}
: {
type: 'unqualified',
sequence: unqualifiedSequence,
key: getEmojiSequenceString(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, getEmojiSequenceString),
getEmojiSequenceString
);
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[getEmojiSequenceString(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

@ -113,11 +113,24 @@ function mergeComponentTypes(value: EmojiComponentType[]) {
return '[' + value.join(',') + ']'; 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 * Map item
*/ */
type EmojiComponentsMapItemSequence = (EmojiComponentType | number)[]; type EmojiComponentsMapItemSequence = (EmojiComponentType | number)[];
interface EmojiComponentsMapItem { export interface EmojiComponentsMapItem {
// Name, with `{skin-tone-1}` (type + index) placeholders // Name, with `{skin-tone-1}` (type + index) placeholders
name: string; name: string;
@ -135,17 +148,16 @@ interface EmojiComponentsMapItem {
* Only sequences with components are returned * Only sequences with components are returned
*/ */
export function getEmojiComponentsMap( export function getEmojiComponentsMap(
testData: EmojiTestDataItem[] testData: EmojiTestDataItem[],
componentsMap?: EmojiTestDataComponentsMap
): EmojiComponentsMapItem[] { ): EmojiComponentsMapItem[] {
// Prepare stuff // Prepare stuff
const mappedTestData = mapEmojiTestDataBySequence( const components =
testData, componentsMap ||
getEmojiSequenceString mapEmojiTestDataComponents(
); mapEmojiTestDataBySequence(testData, getEmojiSequenceString),
const components = mapEmojiTestDataComponents( getEmojiSequenceString
mappedTestData, );
getEmojiSequenceString
);
// Function to clean sequence // Function to clean sequence
const cleanSequence = (sequence: number[]): string => { const cleanSequence = (sequence: number[]): string => {
@ -160,7 +172,7 @@ export function getEmojiComponentsMap(
interface SplitListItem { interface SplitListItem {
item: EmojiTestDataItem; item: EmojiTestDataItem;
split: SplitEmojiName; split: SplitEmojiName;
components: EmojiComponentType[]; components: ComponentsCount;
} }
type SplitList = Record<string, SplitListItem>; type SplitList = Record<string, SplitListItem>;
const splitData = Object.create(null) as Record<string, SplitList>; const splitData = Object.create(null) as Record<string, SplitList>;
@ -179,16 +191,18 @@ export function getEmojiComponentsMap(
// Create unique key based on component types // Create unique key based on component types
let sequenceKey = defaultSplitDataKey; let sequenceKey = defaultSplitDataKey;
const itemComponents: EmojiComponentType[] = []; const itemComponents: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
if (split.components) { if (split.components) {
split.variations?.forEach((item) => { split.variations?.forEach((item) => {
if (typeof item !== 'string') { if (typeof item !== 'string') {
itemComponents.push(item.type); itemComponents[item.type]++;
} }
}); });
if (itemComponents.length) { sequenceKey =
sequenceKey = mergeComponentTypes(itemComponents); mergeComponentsCount(itemComponents) || defaultSplitDataKey;
}
} }
// Get item if already exists // Get item if already exists
@ -228,11 +242,9 @@ export function getEmojiComponentsMap(
// Function to get item // Function to get item
const getItem = ( const getItem = (
components: EmojiComponentType[] components: ComponentsCount
): EmojiComponentsMapItem | undefined => { ): EmojiComponentsMapItem | undefined => {
const key = components.length const key = mergeComponentsCount(components) || defaultSplitDataKey;
? mergeComponentTypes(components)
: defaultSplitDataKey;
const item = items[key]; const item = items[key];
if (!item) { if (!item) {
return; return;
@ -253,15 +265,19 @@ export function getEmojiComponentsMap(
}); });
// Get name // Get name
let counter = 0; const counter: ComponentsCount = {
'hair-style': 0,
'skin-tone': 0,
};
const nameVariations = variations?.map((chunk) => { const nameVariations = variations?.map((chunk) => {
if (typeof chunk === 'string') { if (typeof chunk === 'string') {
return chunk; return chunk;
} }
if (components[counter] !== chunk.type) { const count = counter[chunk.type]++;
if (components[chunk.type] < count) {
throw new Error('Bad variations order'); throw new Error('Bad variations order');
} }
return `{${chunk.type}-${counter++}}`; return `{${chunk.type}-${count}}`;
}); });
const name = const name =
split.base + split.base +
@ -277,16 +293,21 @@ export function getEmojiComponentsMap(
const checkChildren = ( const checkChildren = (
parent: EmojiComponentsMapItem, parent: EmojiComponentsMapItem,
components: EmojiComponentType[] components: ComponentsCount
): boolean => { ): boolean => {
// Attempt to add each type // Attempt to add each type
let found = false; let found = false;
for (const key in emojiComponents) { for (const key in emojiComponents) {
const type = key as EmojiComponentType; const type = key as EmojiComponentType;
const childComponents = components.concat([type]);
// Find child item
const childComponents = {
...components,
};
childComponents[type]++;
const childItem = getItem(childComponents);
// Get sequence for child item // Get sequence for child item
const childItem = getItem(childComponents);
if (childItem) { if (childItem) {
found = true; found = true;
@ -305,9 +326,17 @@ export function getEmojiComponentsMap(
}; };
// Get main item // Get main item
const mainItem = getItem([]); const mainItem = getItem({
'hair-style': 0,
'skin-tone': 0,
});
if (mainItem) { if (mainItem) {
if (checkChildren(mainItem, [])) { if (
checkChildren(mainItem, {
'hair-style': 0,
'skin-tone': 0,
})
) {
// Found item with children // Found item with children
results.push(mainItem); results.push(mainItem);
} }

View File

@ -37,24 +37,24 @@ export function guessQualifiedEmojiSequence(sequence: number[]): number[] {
} }
/** /**
* Add qualified variations to emojis * Get qualified variations for emojis
* *
* Also converts list to UTF-32 as needed * Also converts list to UTF-32 as needed and removes duplicate items
* *
* `testData`, returned by parseEmojiTestFile() is used to check which emojis have `FE0F` variations. * `testData`, returned by parseEmojiTestFile() is used to check which emojis have `FE0F` variations.
* 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 addQualifiedEmojiVariations( export function getQualifiedEmojiVariations(
sequences: number[][], sequences: number[][],
testData?: (number[] | EmojiTestDataItem)[] testData?: (number[] | EmojiTestDataItem)[]
): number[][]; ): number[][];
export function addQualifiedEmojiVariations( export function getQualifiedEmojiVariations(
sequences: number[][], sequences: number[][],
testData: (number[] | EmojiTestDataItem)[], testData: (number[] | EmojiTestDataItem)[],
toString: (value: number[]) => string toString: (value: number[]) => string
): string[]; ): string[];
export function addQualifiedEmojiVariations( export function getQualifiedEmojiVariations(
sequences: number[][], sequences: number[][],
testData: (number[] | EmojiTestDataItem)[] = [], testData: (number[] | EmojiTestDataItem)[] = [],
toString?: (value: number[]) => string toString?: (value: number[]) => string

View File

@ -105,7 +105,8 @@ export {
parseEmojiTestFile, parseEmojiTestFile,
getQualifiedEmojiSequencesMap, getQualifiedEmojiSequencesMap,
} from './emoji/test/parse'; } from './emoji/test/parse';
export { addQualifiedEmojiVariations as addOptionalEmojiVariations } from './emoji/test/variations'; export { getQualifiedEmojiVariations } from './emoji/test/variations';
export { getEmojisSequencesToCopy } from './emoji/test/copy';
export { export {
createOptimisedRegex, createOptimisedRegex,
createOptimisedRegexForEmojiSequences, createOptimisedRegexForEmojiSequences,

View File

@ -6,7 +6,7 @@ import {
getQualifiedEmojiSequencesMap, getQualifiedEmojiSequencesMap,
parseEmojiTestFile, parseEmojiTestFile,
} from '../lib/emoji/test/parse'; } from '../lib/emoji/test/parse';
import { addQualifiedEmojiVariations } from '../lib/emoji/test/variations'; import { getQualifiedEmojiVariations } from '../lib/emoji/test/variations';
describe('Qualified variations of emoji sequences', () => { describe('Qualified variations of emoji sequences', () => {
async function fetchEmojiTestData(): Promise<string | undefined> { async function fetchEmojiTestData(): Promise<string | undefined> {
@ -62,7 +62,7 @@ describe('Qualified variations of emoji sequences', () => {
'1F9D7 1F3FE 200D 2640 FE0F', '1F9D7 1F3FE 200D 2640 FE0F',
'1F9D7 1F3FF 200D 2642 ', '1F9D7 1F3FF 200D 2642 ',
].map(getEmojiSequenceFromString); ].map(getEmojiSequenceFromString);
const results = addQualifiedEmojiVariations(sequences); const results = getQualifiedEmojiVariations(sequences);
expect( expect(
results.map((sequence) => results.map((sequence) =>
getEmojiSequenceString(sequence, { getEmojiSequenceString(sequence, {
@ -135,7 +135,7 @@ describe('Qualified variations of emoji sequences', () => {
// fake keycap, not in test file // fake keycap, not in test file
'2345 20E3 200D 1235', '2345 20E3 200D 1235',
].map(getEmojiSequenceFromString); ].map(getEmojiSequenceFromString);
const results = addQualifiedEmojiVariations( const results = getQualifiedEmojiVariations(
sequences, sequences,
testDataSequences testDataSequences
); );

View File

@ -22,6 +22,8 @@ import {
SplitEmojiName, SplitEmojiName,
getEmojiComponentsMap, getEmojiComponentsMap,
} from '../lib/emoji/test/name'; } from '../lib/emoji/test/name';
import { getEmojisSequencesToCopy } from '../lib/emoji/test/copy';
import { getQualifiedEmojiVariations } from '../lib/emoji/test/variations';
describe('Testing unicode test data', () => { describe('Testing unicode test data', () => {
async function fetchEmojiTestData(): Promise<string | undefined> { async function fetchEmojiTestData(): Promise<string | undefined> {
@ -547,7 +549,7 @@ describe('Testing unicode test data', () => {
sequence: [0x1f469, 'skin-tone'], sequence: [0x1f469, 'skin-tone'],
children: { children: {
'hair-style': { 'hair-style': {
name: 'woman: {skin-tone-0}, {hair-style-1}', name: 'woman: {skin-tone-0}, {hair-style-0}',
sequence: [ sequence: [
0x1f469, 0x1f469,
'skin-tone', 'skin-tone',
@ -560,8 +562,108 @@ describe('Testing unicode test data', () => {
'hair-style': { 'hair-style': {
name: 'woman: {hair-style-0}', name: 'woman: {hair-style-0}',
sequence: [0x1f469, 0x200d, 'hair-style'], sequence: [0x1f469, 0x200d, 'hair-style'],
children: {
'skin-tone': {
name: 'woman: {skin-tone-0}, {hair-style-0}',
sequence: [
0x1f469,
'skin-tone',
0x200d,
'hair-style',
],
},
},
},
},
});
// Item with multiple skin tones
const item4 = map.find(
(item) => sequenceToString(item.sequence) === '1f46b'
);
expect(item4).toEqual({
name: 'woman and man holding hands',
sequence: [0x1f46b],
children: {
'skin-tone': {
name: 'woman and man holding hands: {skin-tone-0}',
sequence: [0x1f46b, 'skin-tone'],
children: {
'skin-tone': {
name: 'woman and man holding hands: {skin-tone-0}, {skin-tone-1}',
sequence: [
0x1f469,
'skin-tone',
0x200d,
0x1f91d,
0x200d,
0x1f468,
'skin-tone',
],
},
},
}, },
}, },
}); });
}); });
it('Checking for missing sequences', () => {
if (!data) {
console.warn('Test skipped: test data is not available');
return;
}
const testData = parseEmojiTestFile(data);
const sequences = getQualifiedEmojiVariations(
testData.map((item) => item.sequence),
testData
);
const missing = getEmojisSequencesToCopy(sequences, testData);
// Should be 30 entries for 15.0
// TODO: update for newer versions
expect(missing.length).toBe(30);
// Two identical tones. Not a valid emoji, but optimises regex
expect(
missing.find(
(item) => item.sourceName === 'handshake: light skin tone'
)
).toEqual({
source: [0x1f91d, 0x1f3fb],
sourceName: 'handshake: light skin tone',
target: [0x1faf1, 0x1f3fb, 0x200d, 0x1faf2, 0x1f3fb],
targetName: 'handshake: light skin tone, light skin tone',
});
// Check with custom data: only base icon
const missing2 = getEmojisSequencesToCopy([[0x1f91d]], testData);
// Missing icons: [skin-tone], [skin-tone, skin-tone]
expect(missing2.length).toBe(5 + 5 * 5);
expect(
missing2.find(
(item) => item.targetName === 'handshake: light skin tone'
)
).toEqual({
source: [0x1f91d],
sourceName: 'handshake',
target: [0x1f91d, 0x1f3fb],
targetName: 'handshake: light skin tone',
});
expect(
missing2.find(
(item) =>
item.targetName ===
'handshake: medium-light skin tone, light skin tone'
)
).toEqual({
// Should be copied from first component match
source: [0x1f91d, 0x1f3fc],
sourceName: 'handshake: medium-light skin tone',
target: [0x1faf1, 0x1f3fc, 0x200d, 0x1faf2, 0x1f3fb],
targetName: 'handshake: medium-light skin tone, light skin tone',
});
});
}); });