Commit 36ac5cfe authored by Martin Kraft's avatar Martin Kraft Committed by Joram Wilander

PLT-7500: Adds emoji skintone support. (#177)

* Adds emoji skintone support.

* Fixes paths for the tests to run in build/emoji.
parent 84ce606d
.PHONY: build test run clean stop check-style run-unit emojis
BUILD_SERVER_DIR = ../mattermost-server
EMOJI_TOOLS_DIR = ./build/emoji
check-style: .yarninstall
@echo Checking for style guide compliance
......@@ -67,4 +68,6 @@ clean:
rm -f .yarninstall
emojis:
./make-emojis
gem install bundler
bundle install --gemfile=$(EMOJI_TOOLS_DIR)/Gemfile
BUNDLE_GEMFILE=$(EMOJI_TOOLS_DIR)/Gemfile bundle exec $(EMOJI_TOOLS_DIR)/make-emojis
source 'https://rubygems.org'
gem 'chunky_png'
gem 'gemoji', '~> 3.0.0'
gem 'sprite-factory'
group :test do
gem 'fastimage'
gem 'minitest'
end
GEM
remote: https://rubygems.org/
specs:
chunky_png (1.3.8)
fastimage (2.1.0)
gemoji (3.0.0)
minitest (5.10.1)
sprite-factory (1.7.1)
PLATFORMS
ruby
DEPENDENCIES
chunky_png
fastimage
gemoji (~> 3.0.0)
minitest
sprite-factory
BUNDLED WITH
1.15.4
require 'emoji'
require 'fileutils'
# Code in this class largely taken from https://github.com/github/gemoji
module Mattermost
class AppleEmojiExtractor
attr_reader :size
def initialize(size)
@size = size
end
def png(emoji)
emoji_has_skintone = emoji.split('').map(&:strip).select do |char|
SKIN_TONE_MAP.values.include?(char)
end.any?
each do |glyph_name, _, binread|
if emoji_has_skintone
next unless glyph_name =~ /\.[1-5]($|\.)/
end
matches = glyph_name_to_emoji(glyph_name)
next unless matches && (matches.include?(emoji) || matches.include?(emoji + "\u{fe0f 200d 2642}"))
return binread.call
end
nil
end
private
EMOJI_TTF = '/System/Library/Fonts/Apple Color Emoji.ttc'.freeze
def each(&block)
return to_enum(__method__) unless block_given?
File.open(EMOJI_TTF, 'rb') do |file|
font_offsets = parse_ttc(file)
file.pos = font_offsets[0]
tables = parse_tables(file)
glyph_index = extract_glyph_index(file, tables)
each_glyph_bitmap(file, tables, glyph_index, &block)
end
end
def glyph_name_to_emoji(glyph_name)
zwj = Emoji::ZERO_WIDTH_JOINER
v16 = Emoji::VARIATION_SELECTOR_16
if glyph_name =~ /^u(#{FAMILY}|#{COUPLE}|#{KISS})\.([#{FAMILY_MAP.keys.join('')}]+)$/
if $1 == FAMILY ? $2 == "MWB" : $2 == "WM"
raw = [$1.hex].pack('U')
else
if $1 == COUPLE
middle = "#{zwj}\u{2764}#{v16}#{zwj}" # heavy black heart
elsif $1 == KISS
middle = "#{zwj}\u{2764}#{v16}#{zwj}\u{1F48B}#{zwj}" # heart + kiss mark
else
middle = zwj
end
raw = $2.split('').map { |c| FAMILY_MAP.fetch(c) }.join(middle)
end
candidates = [raw]
else
raw = glyph_name.gsub(/(^|_)u([0-9A-F]+)/) { ($1.empty?? $1 : zwj) + [$2.hex].pack('U') }
raw.sub!(/\.0\b/, '')
raw.sub!(/\.(#{SKIN_TONE_MAP.keys.join('|')})/) { SKIN_TONE_MAP.fetch($1) }
raw.sub!(/\.(#{GENDER_MAP.keys.join('|')})$/) { v16 + zwj + GENDER_MAP.fetch($1) }
candidates = [raw]
candidates << raw.sub(v16, '') if raw.include?(v16)
candidates << raw.gsub(zwj, '') if raw.include?(zwj)
candidates.dup.each { |c| candidates << (c + v16) }
end
candidates
end
GENDER_MAP = {
"M" => "\u{2642}",
"W" => "\u{2640}",
}
SKIN_TONE_MAP = {
"1" => "\u{1F3FB}",
"2" => "\u{1F3FC}",
"3" => "\u{1F3FD}",
"4" => "\u{1F3FE}",
"5" => "\u{1F3FF}",
}.freeze
FAMILY_MAP = {
"B" => "\u{1f466}",
"G" => "\u{1f467}",
"M" => "\u{1f468}",
"W" => "\u{1f469}",
}.freeze
FAMILY = "1F46A"
COUPLE = "1F491"
KISS = "1F48F"
# https://www.microsoft.com/typography/otspec/otff.htm
def parse_ttc(io)
header_name = io.read(4).unpack('a*')[0]
raise unless "ttcf" == header_name
header_version, num_fonts = io.read(4*2).unpack('l>N')
# parse_version(header_version) #=> 2.0
io.read(4 * num_fonts).unpack('N*')
end
def parse_tables(io)
sfnt_version, num_tables = io.read(4 + 2*4).unpack('Nn')
# sfnt_version #=> 0x00010000
num_tables.times.each_with_object({}) do |_, tables|
tag, checksum, offset, length = io.read(4 + 4*3).unpack('a4N*')
tables[tag] = {
checksum: checksum,
offset: offset,
length: length,
}
end
end
GlyphIndex = Struct.new(:length, :name_index, :names) do
def name_for(glyph_id)
index = name_index[glyph_id]
names[index - 257]
end
def each(&block)
length.times(&block)
end
def each_with_name
each do |glyph_id|
yield glyph_id, name_for(glyph_id)
end
end
end
def extract_glyph_index(io, tables)
postscript_table = tables.fetch('post')
io.pos = postscript_table[:offset]
end_pos = io.pos + postscript_table[:length]
parse_version(io.read(32).unpack('l>')[0]) #=> 2.0
num_glyphs = io.read(2).unpack('n')[0]
glyph_name_index = io.read(2*num_glyphs).unpack('n*')
glyph_names = []
while io.pos < end_pos
length = io.read(1).unpack('C')[0]
glyph_names << io.read(length)
end
GlyphIndex.new(num_glyphs, glyph_name_index, glyph_names)
end
# https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6sbix.html
def each_glyph_bitmap(io, tables, glyph_index)
io.pos = sbix_offset = tables.fetch('sbix')[:offset]
strike = extract_sbix_strike(io, glyph_index.length, size)
glyph_index.each_with_name do |glyph_id, glyph_name|
glyph_offset = strike[:glyph_data_offset][glyph_id]
next_glyph_offset = strike[:glyph_data_offset][glyph_id + 1]
if glyph_offset && next_glyph_offset && glyph_offset < next_glyph_offset
io.pos = sbix_offset + strike[:offset] + glyph_offset
x, y, type = io.read(2*2 + 4).unpack('s2A4')
yield glyph_name, type, -> { io.read(next_glyph_offset - glyph_offset - 8) }
end
end
end
def extract_sbix_strike(io, num_glyphs, image_size)
sbix_offset = io.pos
version, flags, num_strikes = io.read(2*2 + 4).unpack('n2N')
strike_offsets = num_strikes.times.map { io.read(4).unpack('N')[0] }
strike_offsets.each do |strike_offset|
io.pos = sbix_offset + strike_offset
ppem, resolution = io.read(4*2).unpack('n2')
next unless ppem == size
data_offsets = io.read(4 * (num_glyphs+1)).unpack('N*')
return {
ppem: ppem,
resolution: resolution,
offset: strike_offset,
glyph_data_offset: data_offsets,
}
end
return nil
end
def parse_version(num)
major = num >> 16
minor = num & 0xFFFF
"#{major}.#{minor}"
end
end
end
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
#!/usr/bin/env ruby
gem_name, *gem_ver_reqs = 'gemoji', '~> 3.0.0'
gdep = Gem::Dependency.new(gem_name, *gem_ver_reqs)
found_gspec = gdep.matching_specs.max_by(&:version)
if !found_gspec
abort("ERROR: gemoji #{gdep.requirement} is required: `gem install gemoji -v '#{gdep.requirement}'`")
end
puts "using gemoji #{found_gspec.version}"
gem 'gemoji', found_gspec.version
require 'emoji'
require 'fileutils'
require 'json'
require 'open-uri'
require 'sprite_factory'
require 'emoji/extractor'
require_relative './apple_emoji_extractor.rb'
require_relative './unicode_emoji_helper.rb'
project_root = "#{File.dirname(__FILE__)}/../../"
emoji_path = File.join(File.dirname(__FILE__), 'images', 'emoji')
emoji_path = File.join(project_root, 'images', 'emoji')
if File.directory? emoji_path
FileUtils.remove_dir emoji_path
end
Dir.mkdir emoji_path
Emoji.create('mattermost')
FileUtils.cp(File.join(File.dirname(__FILE__), 'images', 'icon64x64.png'), File.join(emoji_path, 'mattermost.png'))
FileUtils.cp(File.join(project_root, 'images', 'icon64x64.png'), File.join(emoji_path, 'mattermost.png'))
custom_aliases = {
"ca" => "canada",
......@@ -37,11 +33,50 @@ custom_aliases.each do |custom, original|
end
end
### Extract and create all of the emoji skin tones ###
unicode_emoji_helper = Mattermost::UnicodeEmojiHelper.new
apple_extractor = Mattermost::AppleEmojiExtractor.new(64)
emoji_added = []
Emoji.all.clone.each do |emoji|
next unless emoji.raw
sequences = unicode_emoji_helper.emoji_modifier_sequences(emoji.raw.split('')[0])
next unless sequences
sequences.each do |sequence|
pngbytes = apple_extractor.png(sequence)
print 'x' if pngbytes.nil?
next unless pngbytes
modifier = sequence.split('')[1]
short_name = Mattermost::UnicodeEmojiHelper::SKIN_TONE_MAP[modifier]
new_name = "#{emoji.name}_#{short_name.gsub(/[\s-]/, '_')}"
new_emoji = Emoji.create(new_name) do |char|
char.category = 'skintone'
char.add_unicode_alias sequence
end
new_emoji.image_filename = "#{new_emoji.hex_inspect}.png"
fullpath = "#{emoji_path}/#{new_emoji.image_filename}"
unless File.file?(fullpath)
File.open(fullpath, 'wb') { |f| f.write pngbytes }
end
print '.'
emoji_added << sequence
end
end
puts "\nThe following #{emoji_added.length} emoji were added:"
print emoji_added
blacklist = ['basecamp', 'basecampy']
categories = []
File.open(File.join(File.dirname(__FILE__), 'utils', 'emoji.jsx'), 'w') do |f|
File.open(File.join(project_root, 'utils', 'emoji.jsx'), 'w') do |f|
emojis = []
emoji_indices_by_alias = []
emoji_indices_by_unicode = []
......@@ -97,11 +132,9 @@ File.open(File.join(File.dirname(__FILE__), 'utils', 'emoji.jsx'), 'w') do |f|
f.write("export const EmojiIndicesByCategory = new Map(#{JSON.generate emoji_indices_by_category});\n\n")
f.write("/* eslint-enable */")
puts "wrote #{emojis.length} emojis to utils/emoji.jsx"
puts "\nwrote #{emojis.length} emojis to utils/emoji.jsx"
end
require 'emoji/extractor'
Emoji::Extractor.new(64, emoji_path).extract!
Dir["#{Emoji.images_path}/*.png"].each do |png|
......@@ -115,17 +148,6 @@ puts "images written to images/emoji"
### Spritesheet Generation ###
['sprite-factory', 'chunky_png'].each do |name|
gdep = Gem::Dependency.new(name, '>1.0')
found_gspec = gdep.matching_specs.max_by(&:version)
if !found_gspec
abort("ERROR: #{name} is required for spritesheet generation: `gem install #{name}'`")
end
end
require 'sprite_factory'
css_rules = [
'@charset "UTF-8";',
'.emojisprite-preview {',
......@@ -161,7 +183,7 @@ css_rules = [
'}',
]
spritesheet_path = File.join(File.dirname(__FILE__), 'images', 'emoji-sheets')
spritesheet_path = File.join(project_root, 'images', 'emoji-sheets')
if File.directory? spritesheet_path
FileUtils.remove_dir spritesheet_path
end
......@@ -219,5 +241,5 @@ categories.each do |category|
end
File.write(File.join(File.dirname(__FILE__), 'sass', 'components', '_emojisprite.scss'), css_rules.join("\n"))
File.write(File.join(project_root, 'sass', 'components', '_emojisprite.scss'), css_rules.join("\n"))
puts "sprite stylesheet written to sass/components/_emojisprite.scss"
module Mattermost
class UnicodeEmojiHelper
SKIN_TONE_MAP = {
"\u{1F3FB}" => 'light skin tone',
"\u{1F3FC}" => 'medium-light skin tone',
"\u{1F3FD}" => 'medium skin tone',
"\u{1F3FE}" => 'medium-dark skin tone',
"\u{1F3FF}" => 'dark skin tone'
}.freeze
def initialize
path = File.join(File.dirname(__FILE__), './emoji-sequences.txt')
@emoji_sequences = File.readlines(path)
end
def all_emoji_modifier_bases
match_string = '; Emoji_Modifier_Sequence ;'
lines = @emoji_sequences.select { |l| l.include?(match_string) }
hex_strings = lines.map { |l| /^[0-9A-F]{4,5}/.match(l)[0] }
integers = hex_strings.map { |s| s.to_i(16) }
integers.map { |i| [i].pack('U') }
end
def emoji_modifier_base?(code_point)
all_emoji_modifier_bases.include?(code_point)
end
def emoji_modifier_sequences(emoji_modifier_base)
return nil unless emoji_modifier_base?(emoji_modifier_base)
SKIN_TONE_MAP.each_key.map do |skin_tone_modifier|
[emoji_modifier_base, skin_tone_modifier].join('')
end
end
end
end
require 'minitest/autorun'
require_relative './unicode_emoji_helper.rb'
module Mattermost
class UnicodeEmojiHelperTest < Minitest::Test
def setup
@unicode_emoji_helper = UnicodeEmojiHelper.new
end
def test_all_emoji_modifier_bases_count
bases = @unicode_emoji_helper.all_emoji_modifier_bases
assert_equal bases.count, 510
end
def test_emoji_modifier_base?
valid_emoji_modifier_base = "\u{1F442}"
invalid_emoji_modifier_base = "\u{1F1E6}"
assert_equal @unicode_emoji_helper.emoji_modifier_base?(valid_emoji_modifier_base), true
assert_equal @unicode_emoji_helper.emoji_modifier_base?(invalid_emoji_modifier_base), false
end
def test_emoji_modifier_sequences_valid_base_count
sequences = @unicode_emoji_helper.emoji_modifier_sequences("\u{1F483}")
assert_equal sequences.count, 5
end
def test_emoji_modifier_sequences_valid_base_combines_skin_tone_and_base
base = "\u{1F483}"
sequence = @unicode_emoji_helper.emoji_modifier_sequences(base)[0]
sequence_parts = sequence.split('')
assert_equal sequence_parts.count, 2
assert_equal sequence_parts[0], base
expected_modifier = UnicodeEmojiHelper::SKIN_TONE_MAP.keys.first
assert_equal sequence_parts[1], expected_modifier
end
end
end
......@@ -118,7 +118,7 @@ export default class EmojiPicker extends React.Component {
name: 'flags',
className: 'fa fa-flag-o',
id: 'emoji_picker.flags',
message: 'Fags',
message: 'Flags',
offset: 0,
enable: false
},
......
......@@ -14,6 +14,7 @@ import * as Emoticons from 'utils/emoticons.jsx';
import Suggestion from './suggestion.jsx';
const MIN_EMOTICON_LENGTH = 2;
const EMOJI_CATEGORY_SUGGESTION_BLACKLIST = ['skintone'];
class EmoticonSuggestion extends Suggestion {
render() {
......@@ -79,7 +80,7 @@ export default class EmoticonProvider {
if (emoji.aliases) {
// This is a system emoji so it may have multiple names
for (const alias of emoji.aliases) {
if (alias.indexOf(partialName) !== -1) {
if (!EMOJI_CATEGORY_SUGGESTION_BLACKLIST.includes(emoji.category) && alias.indexOf(partialName) !== -1) {
matched.push({name: alias, emoji});
break;
}
......
images/emoji-sheets/activity-1.png

271 KB | W: | H:

images/emoji-sheets/activity-1.png

271 KB | W: | H:

images/emoji-sheets/activity-1.png
images/emoji-sheets/activity-1.png
images/emoji-sheets/activity-1.png
images/emoji-sheets/activity-1.png
  • 2-up
  • Swipe
  • Onion skin