この記事は前回と前々回の続きです。
https://qiita.com/vipper36/items/22169594f6546a9c01b9
https://qiita.com/vipper36/items/2dd7fcda17a51701dad0
いよいよ本命としてMisskey端末クライアント向けに(ほぼ)全部のカスタム絵文字が入ったMisskey外字フォントを生成するコードを書いてみました。今回はMisskey最大手のMisskey.ioサーバー向けとします。
なお生成される外字フォントは画像の高さ別に265個となります。本当はOpenTypeのsbix仕様のStrikeを複数作って一つのフォントに収められたら良かったのですが、何か上手く行かなかったので妥協しました。代わりにフォントコレクションのOTCにすると良いかもしれませんが、自分には不要であるため今回は省略します。
それとMisskeyカスタム絵文字は一部がパブリックドメインなのですが、他の絵文字のライセンスは良く分からないため生成したフォント自体は配布しません。その代わり生成するコードを置いておくので自分と同じように端末クライアントを作ってる方などは自分で生成していただけると幸いです。
import requests
# https://friendsyu.me/api-doc#operation/emojis より
response = requests.get("https://misskey.io/api/emojis") # Misskey.io以外ならここを変える
import orjson
emoji_dict = None
emojis_root = orjson.loads(response.text)
emoji_lists = emojis_root["emojis"]
emoji_dict = {i["name"]:i["url"] for i in emoji_lists} # aliasesは検索用で本文には使えない?
emoji_url_list = [i["url"] for i in emoji_lists]
import os
if not os.path.exists("icons"):
os.makedirs("icons")
import multiprocessing
import psutil
# Function to download a file
def download_file(url):
try:
file_name = "./icons/" + url.split("/")[-1]
if os.path.exists(file_name):
return (0, f"Skipped file {file_name} as it already exists.")
response = requests.get(url)
if response.status_code == 200:
with open(file_name, 'wb') as f:
f.write(response.content)
return (0, f"Downloaded {file_name}")
else:
return (-1, f"Failed to download {url} - Status code: {response.status_code}")
except Exception as e:
return (-1, f"Failed to download {url} - {str(e)}")
num_cpus = multiprocessing.cpu_count()
#emoji_url_list = emoji_url_list[0:20] # XXX: for debug
child_process_ids = []
for cpu_id in range(num_cpus): # Fork processes
cpid = os.fork()
if cpid: # In the parent process
child_process_ids.append(cpid)
else: # In the child process
os.sched_setaffinity(0, [cpu_id])
# print("%s :::: %s"%(cpu_id, emoji_url_list[cpu_id::num_cpus]))
for url in emoji_url_list[cpu_id::num_cpus]:
err, result = download_file(url)
if err:
print(result)
# Failed to download https://s3.arkjp.net/misskey/ca66f316-da7f-47c4-85be-dfbab6e06893.png - Status code: 404
# Failed to download https://s3.arkjp.net/misskey/92917eb9-9ed6-44f4-9255-241b3508f6a2.png - Status code: 404
# Failed to download https://s3.arkjp.net/misskey/0d7b68c6-22bd-4e96-a683-2b108ff3d131.png - Status code: 404
os._exit(0) # Exit the child process
download_results = {}
for pid in child_process_ids: # Wait for child processes to finish and get exit status
_, exit_status = os.waitpid(pid, 0)
if os.WIFEXITED(exit_status):
download_results[pid] = os.WEXITSTATUS(exit_status)
for pid, exit_code in download_results.items(): # Check download results
if exit_code == 0:
print(f"Download process {pid} completed successfully.")
else:
print(f"Download process {pid} failed with exit code {exit_code}")
emoji_file_list = {i:("./icons/" + j.split("/")[-1]) for (i,j) in emoji_dict.items()}
name_to_char = {}
# pip install python-magic
import magic
m = magic.Magic()
#m_mime = magic.Magic(mime=True) # animated/gif is unsupported
import images2font2
i = 0x00100000 + 339
font_images = {}
def add_font_images(key, val_dict):
global font_images
if key not in font_images:
font_images[key] = {}
font_images[key] |= val_dict
from PIL import Image, ImageSequence
from io import BytesIO
import math
## XXXXX: per-height multiple strikes is not working?
for name, path in emoji_file_list.items():
if not os.path.exists(path):
print("File %s is not exsists so the download was failed for unknown reason..."%path)
continue
fformat = m.from_file(path)
if "PNG "in fformat:
img = Image.open(path)
add_font_images(img.height, {i: path})
name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
i += 1
elif "JPEG "in fformat:
img = Image.open(path)
add_font_images(img.height, {i: (path, "jpg ")})
name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
i += 1
elif "GIF "in fformat or "Web/P "in fformat:
if "YUV" in fformat:
print("YUV Web/P is not supported") # TODO: will try to convert to YUV TIFF
continue
src_data = open(path, 'rb').read()
src_io = BytesIO(src_data)
src_image = Image.open(src_io)
if src_image.is_animated:
frames = [frame.copy() for frame in ImageSequence.Iterator(src_image)]
png_images = []
for frame in frames:
png_io = BytesIO()
frame.save(png_io, format='PNG')
add_font_images(frame.height, {i: png_io.getvalue()})
name_to_char |= {name: [i, math.ceil(frame.width/(frame.height/2))]}
i += 1
else:
png_io = BytesIO()
src_image.save(png_io, format='PNG')
add_font_images(src_image.height, {i: png_io.getvalue()})
name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
i += 1
elif "SVG "in fformat:
print("SVG is not supported") # TODO: will try to convert to OpenType-SVG
else:
print("File &s is unrecognized format %s"%(path, fformat))
#print(name_to_char)
import pickle
with open('misskey_name_to_char.pkl', 'wb') as f:
pickle.dump(name_to_char, f)
import os
if not os.path.exists("fonts"):
os.makedirs("fonts")
for i, fontimgs in font_images.items():
images2font2.images2font(fontimgs, "MisskeyCustomEmojiFont-%s"%i, "ミスキーカスタム絵文字フォント-%s"%i, font_path="./fonts/misskey-%s.otf"%i, cache_update=False)
import os
os.system("fc-cache -f")
from fontTools.ttLib import TTFont
from fontTools.fontBuilder import FontBuilder
from fontTools.ttLib.ttFont import newTable
from fontTools.ttLib.tables._s_b_i_x import table__s_b_i_x
from fontTools.ttLib.tables import sbixGlyph, sbixStrike
from fontTools.pens.t2CharStringPen import T2CharStringPen
# everything should work:
# font_images = {0x10FFFF: img_path}}
# font_images = {0x10FFFF: img_data}}
# font_images = {0x10FFFF: (img_path, graphicType in sbix like "png ")}
# font_images = {0x10FFFF: (img_data, graphicType in sbix like "png ")}
# XXX: 順序が保存されない?
# WARNING: ALL IMAGES SHOULD HAVE SAME HEIGHT
from PIL import Image
from io import BytesIO
def images2font(font_images={}, familyName_en = "Image2Font", familyName_ja = "Image2Font",
font_path="./my_font.otf", styleName = "Monospace", version = "0.1", install=True, cache_update=True, img_width=1000, img_height=1000, ppem=256):
fb = FontBuilder(unitsPerEm = 1000, isTTF=False)
imgglyph_ids = [hex(i) for i in font_images.keys()]
glyphs = [".notdef", ".null", ".CR"] + imgglyph_ids
fb.setupGlyphOrder(glyphs)
fb.setupCharacterMap({k: hex(k) for k in font_images.keys()})
advanceWidths = {".notdef": img_width, ".null": 0, ".CR": 0} | {i: img_width for i in imgglyph_ids}
nameStrings = dict(
familyName=dict(en=familyName_en, ja=familyName_ja),
styleName=dict(en=styleName, ja="Mono"),
uniqueFontIdentifier="fontBuilder: " + familyName_en + "." + styleName,
fullName=familyName_en + "-" + styleName,
psName=familyName_en + "-" + styleName,
version="Version " + version,
)
# Create an sbix table
sbix = newTable("sibx")
sbix_table = table__s_b_i_x()
#ppem = 12
#ppem = 256
sbix_strikes = {}
for glyph_name, image in zip(imgglyph_ids, list(font_images.values())):
image_data = None
if type(image) is tuple:
image = image[0]
graphicType = image[1]
else:
graphicType = "png "
if type(image) is str:
with open(image, "rb") as image_file:
image_data = image_file.read()
else:
image_data = image
image = Image.open(BytesIO(image_data))
if image.height not in sbix_strikes:
sbix_strikes[image.height] = sbixStrike.Strike(ppem=image.height, resolution=72)
sbix_table.numStrikes += 1
sbix_strike = sbix_strikes[image.height]
sbix_glyph = sbixGlyph.Glyph(glyphName=glyph_name, graphicType=graphicType, imageData=image_data) # referenceGlyphName=glyph_name,
sbix_strike.glyphs[glyph_name] = sbix_glyph
sbix_table.strikes[image.height] = sbix_strike
fb.font["sbix"] = sbix_table
pen = T2CharStringPen(1000, None)
pen.moveTo((0, 0))
pen.lineTo((0, img_height))
pen.lineTo((img_width, img_height))
pen.lineTo((img_width, 0))
pen.closePath()
charString = pen.getCharString()
charStrings = {
".notdef": charString,
".null": charString,
".CR": charString,
} | {i: charString for i in imgglyph_ids}
fb.setupCFF(nameStrings["psName"], {"FullName": nameStrings["psName"]}, charStrings, {})
# lsb = {gn: cs.calcBounds(None)[0] for gn, cs in charStrings.items()}
# lsb2 = {gn: cs.calcBounds(None)[1] for gn, cs in charStrings.items()}
h_metrics = {}
for gn, advanceWidth in advanceWidths.items():
# metrics[gn] = (0, 0)
# h_metrics[gn] = (advanceWidth, lsb[gn])
h_metrics[gn] = (advanceWidth, 0)
fb.setupHorizontalMetrics(h_metrics)
v_metrics = {}
for gn, advanceWidth in advanceWidths.items():
# v_metrics[gn] = (1000, lsb2[gn])
v_metrics[gn] = (1000, 0)
fb.setupVerticalMetrics(v_metrics)
fb.setupHorizontalHeader(ascent=img_height, descent=0) # ascent=824, descent=200
fb.setupVerticalHeader(ascent=img_width, descent=0) # ascent=824, descent=200
fb.setupNameTable(nameStrings)
fb.setupOS2(sTypoAscender=img_height, sTypoDescender=0, usWinAscent=img_height, usWinDescent=0)
fb.setupPost()
#fb.setupDummyDSIG()
fb.save(font_path)
if install == True:
import os
os.system("cp %s ~/.local/share/fonts/" % font_path)
if cache_update == True:
import os
os.system("fc-cache -f")
なお misskey_name_to_char.pkl には {"名前": [文字番号, 半角換算の必要文字幅], ...} が入っています。
それとフォントのグリフ幅は画像幅を無視して全角 (1000) としていますが、端末はそれを無視してこの文字範囲を半角扱いするようです。そのためMisskeyのテキストのカスタム絵文字コマンドを外字フォールバック対応の端末向けに変換して表示するコードはこんな感じになります。
import pickle
misskey_name_to_char = None
with open("misskey_name_to_char.pkl", "rb") as pkl:
misskey_name_to_char = pickle.load(pkl)
import re
def replace_custom_emoji(text):
pattern = re.compile(':'+(':|:'.join(map(re.escape, misskey_name_to_char.keys())))+":")
def replace(match):
item = misskey_name_to_char[match.group(0)[1:-1]]
return ""+chr(item[0]) + (" "*(item[1]-1))
return pattern.sub(replace, text)
print(replace_custom_emoji(":misskey::mi_::misuhai::note::nyanpuppu::ablobaww:"))